mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-25 06:48:12 -05:00
Compare commits
30 Commits
fix/ci-com
...
v0.5.63
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b09f683072 | ||
|
|
a8bb0db660 | ||
|
|
af82820a28 | ||
|
|
4372841797 | ||
|
|
5e8c843241 | ||
|
|
7bf3d73ee6 | ||
|
|
7ffc11a738 | ||
|
|
be578e2ed7 | ||
|
|
f415e5edc4 | ||
|
|
13a6e6c3fa | ||
|
|
f5ab7f21ae | ||
|
|
bfb6fffe38 | ||
|
|
4fbec0a43f | ||
|
|
585f5e365b | ||
|
|
3792bdd252 | ||
|
|
eb5d1f3e5b | ||
|
|
54ab82c8dd | ||
|
|
f895bf469b | ||
|
|
dd3209af06 | ||
|
|
b6ba3b50a7 | ||
|
|
b304233062 | ||
|
|
57e4b49bd6 | ||
|
|
e12dd204ed | ||
|
|
3d9d9cbc54 | ||
|
|
0f4ec962ad | ||
|
|
4827866f9a | ||
|
|
3e697d9ed9 | ||
|
|
4431a1a484 | ||
|
|
4d1a9a3f22 | ||
|
|
eb07a080fb |
@@ -1,35 +0,0 @@
|
|||||||
---
|
|
||||||
paths:
|
|
||||||
- "apps/sim/components/emcn/**"
|
|
||||||
---
|
|
||||||
|
|
||||||
# EMCN Components
|
|
||||||
|
|
||||||
Import from `@/components/emcn`, never from subpaths (except CSS files).
|
|
||||||
|
|
||||||
## CVA vs Direct Styles
|
|
||||||
|
|
||||||
**Use CVA when:** 2+ variants (primary/secondary, sm/md/lg)
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
const buttonVariants = cva('base-classes', {
|
|
||||||
variants: { variant: { default: '...', primary: '...' } }
|
|
||||||
})
|
|
||||||
export { Button, buttonVariants }
|
|
||||||
```
|
|
||||||
|
|
||||||
**Use direct className when:** Single consistent style, no variations
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
function Label({ className, ...props }) {
|
|
||||||
return <Primitive className={cn('style-classes', className)} {...props} />
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Rules
|
|
||||||
|
|
||||||
- Use Radix UI primitives for accessibility
|
|
||||||
- Export component and variants (if using CVA)
|
|
||||||
- TSDoc with usage examples
|
|
||||||
- Consistent tokens: `font-medium`, `text-[12px]`, `rounded-[4px]`
|
|
||||||
- `transition-colors` for hover states
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
# Global Standards
|
|
||||||
|
|
||||||
## Logging
|
|
||||||
Import `createLogger` from `sim/logger`. Use `logger.info`, `logger.warn`, `logger.error` instead of `console.log`.
|
|
||||||
|
|
||||||
## Comments
|
|
||||||
Use TSDoc for documentation. No `====` separators. No non-TSDoc comments.
|
|
||||||
|
|
||||||
## Styling
|
|
||||||
Never update global styles. Keep all styling local to components.
|
|
||||||
|
|
||||||
## Package Manager
|
|
||||||
Use `bun` and `bunx`, not `npm` and `npx`.
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
---
|
|
||||||
paths:
|
|
||||||
- "apps/sim/**"
|
|
||||||
---
|
|
||||||
|
|
||||||
# Sim App Architecture
|
|
||||||
|
|
||||||
## Core Principles
|
|
||||||
1. **Single Responsibility**: Each component, hook, store has one clear purpose
|
|
||||||
2. **Composition Over Complexity**: Break down complex logic into smaller pieces
|
|
||||||
3. **Type Safety First**: TypeScript interfaces for all props, state, return types
|
|
||||||
4. **Predictable State**: Zustand for global state, useState for UI-only concerns
|
|
||||||
|
|
||||||
## Root-Level Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
apps/sim/
|
|
||||||
├── app/ # Next.js app router (pages, API routes)
|
|
||||||
├── blocks/ # Block definitions and registry
|
|
||||||
├── components/ # Shared UI (emcn/, ui/)
|
|
||||||
├── executor/ # Workflow execution engine
|
|
||||||
├── hooks/ # Shared hooks (queries/, selectors/)
|
|
||||||
├── lib/ # App-wide utilities
|
|
||||||
├── providers/ # LLM provider integrations
|
|
||||||
├── stores/ # Zustand stores
|
|
||||||
├── tools/ # Tool definitions
|
|
||||||
└── triggers/ # Trigger definitions
|
|
||||||
```
|
|
||||||
|
|
||||||
## Feature Organization
|
|
||||||
|
|
||||||
Features live under `app/workspace/[workspaceId]/`:
|
|
||||||
|
|
||||||
```
|
|
||||||
feature/
|
|
||||||
├── components/ # Feature components
|
|
||||||
├── hooks/ # Feature-scoped hooks
|
|
||||||
├── utils/ # Feature-scoped utilities (2+ consumers)
|
|
||||||
├── feature.tsx # Main component
|
|
||||||
└── page.tsx # Next.js page entry
|
|
||||||
```
|
|
||||||
|
|
||||||
## Naming Conventions
|
|
||||||
- **Components**: PascalCase (`WorkflowList`)
|
|
||||||
- **Hooks**: `use` prefix (`useWorkflowOperations`)
|
|
||||||
- **Files**: kebab-case (`workflow-list.tsx`)
|
|
||||||
- **Stores**: `stores/feature/store.ts`
|
|
||||||
- **Constants**: SCREAMING_SNAKE_CASE
|
|
||||||
- **Interfaces**: PascalCase with suffix (`WorkflowListProps`)
|
|
||||||
|
|
||||||
## Utils Rules
|
|
||||||
|
|
||||||
- **Never create `utils.ts` for single consumer** - inline it
|
|
||||||
- **Create `utils.ts` when** 2+ files need the same helper
|
|
||||||
- **Check existing sources** before duplicating (`lib/` has many utilities)
|
|
||||||
- **Location**: `lib/` (app-wide) → `feature/utils/` (feature-scoped) → inline (single-use)
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
---
|
|
||||||
paths:
|
|
||||||
- "apps/sim/**/*.tsx"
|
|
||||||
---
|
|
||||||
|
|
||||||
# Component Patterns
|
|
||||||
|
|
||||||
## Structure Order
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
'use client' // Only if using hooks
|
|
||||||
|
|
||||||
// Imports (external → internal)
|
|
||||||
// Constants at module level
|
|
||||||
const CONFIG = { SPACING: 8 } as const
|
|
||||||
|
|
||||||
// Props interface
|
|
||||||
interface ComponentProps {
|
|
||||||
requiredProp: string
|
|
||||||
optionalProp?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Component({ requiredProp, optionalProp = false }: ComponentProps) {
|
|
||||||
// a. Refs
|
|
||||||
// b. External hooks (useParams, useRouter)
|
|
||||||
// c. Store hooks
|
|
||||||
// d. Custom hooks
|
|
||||||
// e. Local state
|
|
||||||
// f. useMemo
|
|
||||||
// g. useCallback
|
|
||||||
// h. useEffect
|
|
||||||
// i. Return JSX
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Rules
|
|
||||||
|
|
||||||
1. `'use client'` only when using React hooks
|
|
||||||
2. Always define props interface
|
|
||||||
3. Extract constants with `as const`
|
|
||||||
4. Semantic HTML (`aside`, `nav`, `article`)
|
|
||||||
5. Optional chain callbacks: `onAction?.(id)`
|
|
||||||
|
|
||||||
## Component Extraction
|
|
||||||
|
|
||||||
**Extract when:** 50+ lines, used in 2+ files, or has own state/logic
|
|
||||||
|
|
||||||
**Keep inline when:** < 10 lines, single use, purely presentational
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
---
|
|
||||||
paths:
|
|
||||||
- "apps/sim/**/use-*.ts"
|
|
||||||
- "apps/sim/**/hooks/**/*.ts"
|
|
||||||
---
|
|
||||||
|
|
||||||
# Hook Patterns
|
|
||||||
|
|
||||||
## Structure
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface UseFeatureProps {
|
|
||||||
id: string
|
|
||||||
onSuccess?: (result: Result) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useFeature({ id, onSuccess }: UseFeatureProps) {
|
|
||||||
// 1. Refs for stable dependencies
|
|
||||||
const idRef = useRef(id)
|
|
||||||
const onSuccessRef = useRef(onSuccess)
|
|
||||||
|
|
||||||
// 2. State
|
|
||||||
const [data, setData] = useState<Data | null>(null)
|
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
|
||||||
|
|
||||||
// 3. Sync refs
|
|
||||||
useEffect(() => {
|
|
||||||
idRef.current = id
|
|
||||||
onSuccessRef.current = onSuccess
|
|
||||||
}, [id, onSuccess])
|
|
||||||
|
|
||||||
// 4. Operations (useCallback with empty deps when using refs)
|
|
||||||
const fetchData = useCallback(async () => {
|
|
||||||
setIsLoading(true)
|
|
||||||
try {
|
|
||||||
const result = await fetch(`/api/${idRef.current}`).then(r => r.json())
|
|
||||||
setData(result)
|
|
||||||
onSuccessRef.current?.(result)
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return { data, isLoading, fetchData }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Rules
|
|
||||||
|
|
||||||
1. Single responsibility per hook
|
|
||||||
2. Props interface required
|
|
||||||
3. Refs for stable callback dependencies
|
|
||||||
4. Wrap returned functions in useCallback
|
|
||||||
5. Always try/catch async operations
|
|
||||||
6. Track loading/error states
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
---
|
|
||||||
paths:
|
|
||||||
- "apps/sim/**/*.ts"
|
|
||||||
- "apps/sim/**/*.tsx"
|
|
||||||
---
|
|
||||||
|
|
||||||
# Import Patterns
|
|
||||||
|
|
||||||
## Absolute Imports
|
|
||||||
|
|
||||||
**Always use absolute imports.** Never use relative imports.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// ✓ Good
|
|
||||||
import { useWorkflowStore } from '@/stores/workflows/store'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
|
|
||||||
// ✗ Bad
|
|
||||||
import { useWorkflowStore } from '../../../stores/workflows/store'
|
|
||||||
```
|
|
||||||
|
|
||||||
## Barrel Exports
|
|
||||||
|
|
||||||
Use barrel exports (`index.ts`) when a folder has 3+ exports. Import from barrel, not individual files.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// ✓ Good
|
|
||||||
import { Dashboard, Sidebar } from '@/app/workspace/[workspaceId]/logs/components'
|
|
||||||
|
|
||||||
// ✗ Bad
|
|
||||||
import { Dashboard } from '@/app/workspace/[workspaceId]/logs/components/dashboard/dashboard'
|
|
||||||
```
|
|
||||||
|
|
||||||
## No Re-exports
|
|
||||||
|
|
||||||
Do not re-export from non-barrel files. Import directly from the source.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// ✓ Good - import from where it's declared
|
|
||||||
import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types'
|
|
||||||
|
|
||||||
// ✗ Bad - re-exporting in utils.ts then importing from there
|
|
||||||
import { CORE_TRIGGER_TYPES } from '@/app/workspace/.../utils'
|
|
||||||
```
|
|
||||||
|
|
||||||
## Import Order
|
|
||||||
|
|
||||||
1. React/core libraries
|
|
||||||
2. External libraries
|
|
||||||
3. UI components (`@/components/emcn`, `@/components/ui`)
|
|
||||||
4. Utilities (`@/lib/...`)
|
|
||||||
5. Stores (`@/stores/...`)
|
|
||||||
6. Feature imports
|
|
||||||
7. CSS imports
|
|
||||||
|
|
||||||
## Type Imports
|
|
||||||
|
|
||||||
Use `type` keyword for type-only imports:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import type { WorkflowLog } from '@/stores/logs/types'
|
|
||||||
```
|
|
||||||
@@ -1,209 +0,0 @@
|
|||||||
---
|
|
||||||
paths:
|
|
||||||
- "apps/sim/tools/**"
|
|
||||||
- "apps/sim/blocks/**"
|
|
||||||
- "apps/sim/triggers/**"
|
|
||||||
---
|
|
||||||
|
|
||||||
# Adding Integrations
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Adding a new integration typically requires:
|
|
||||||
1. **Tools** - API operations (`tools/{service}/`)
|
|
||||||
2. **Block** - UI component (`blocks/blocks/{service}.ts`)
|
|
||||||
3. **Icon** - SVG icon (`components/icons.tsx`)
|
|
||||||
4. **Trigger** (optional) - Webhooks/polling (`triggers/{service}/`)
|
|
||||||
|
|
||||||
Always look up the service's API docs first.
|
|
||||||
|
|
||||||
## 1. Tools (`tools/{service}/`)
|
|
||||||
|
|
||||||
```
|
|
||||||
tools/{service}/
|
|
||||||
├── index.ts # Export all tools
|
|
||||||
├── types.ts # Params/response types
|
|
||||||
├── {action}.ts # Individual tool (e.g., send_message.ts)
|
|
||||||
└── ...
|
|
||||||
```
|
|
||||||
|
|
||||||
**Tool file structure:**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// tools/{service}/{action}.ts
|
|
||||||
import type { {Service}Params, {Service}Response } from '@/tools/{service}/types'
|
|
||||||
import type { ToolConfig } from '@/tools/types'
|
|
||||||
|
|
||||||
export const {service}{Action}Tool: ToolConfig<{Service}Params, {Service}Response> = {
|
|
||||||
id: '{service}_{action}',
|
|
||||||
name: '{Service} {Action}',
|
|
||||||
description: 'What this tool does',
|
|
||||||
version: '1.0.0',
|
|
||||||
oauth: { required: true, provider: '{service}' }, // if OAuth
|
|
||||||
params: { /* param definitions */ },
|
|
||||||
request: {
|
|
||||||
url: '/api/tools/{service}/{action}',
|
|
||||||
method: 'POST',
|
|
||||||
headers: () => ({ 'Content-Type': 'application/json' }),
|
|
||||||
body: (params) => ({ ...params }),
|
|
||||||
},
|
|
||||||
transformResponse: async (response) => {
|
|
||||||
const data = await response.json()
|
|
||||||
if (!data.success) throw new Error(data.error)
|
|
||||||
return { success: true, output: data.output }
|
|
||||||
},
|
|
||||||
outputs: { /* output definitions */ },
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Register in `tools/registry.ts`:**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { {service}{Action}Tool } from '@/tools/{service}'
|
|
||||||
// Add to registry object
|
|
||||||
{service}_{action}: {service}{Action}Tool,
|
|
||||||
```
|
|
||||||
|
|
||||||
## 2. Block (`blocks/blocks/{service}.ts`)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { {Service}Icon } from '@/components/icons'
|
|
||||||
import type { BlockConfig } from '@/blocks/types'
|
|
||||||
import type { {Service}Response } from '@/tools/{service}/types'
|
|
||||||
|
|
||||||
export const {Service}Block: BlockConfig<{Service}Response> = {
|
|
||||||
type: '{service}',
|
|
||||||
name: '{Service}',
|
|
||||||
description: 'Short description',
|
|
||||||
longDescription: 'Detailed description',
|
|
||||||
category: 'tools',
|
|
||||||
bgColor: '#hexcolor',
|
|
||||||
icon: {Service}Icon,
|
|
||||||
subBlocks: [ /* see SubBlock Properties below */ ],
|
|
||||||
tools: {
|
|
||||||
access: ['{service}_{action}', ...],
|
|
||||||
config: {
|
|
||||||
tool: (params) => `{service}_${params.operation}`,
|
|
||||||
params: (params) => ({ ...params }),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
inputs: { /* input definitions */ },
|
|
||||||
outputs: { /* output definitions */ },
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### SubBlock Properties
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
{
|
|
||||||
id: 'fieldName', // Unique identifier
|
|
||||||
title: 'Field Label', // UI label
|
|
||||||
type: 'short-input', // See SubBlock Types below
|
|
||||||
placeholder: 'Hint text',
|
|
||||||
required: true, // See Required below
|
|
||||||
condition: { ... }, // See Condition below
|
|
||||||
dependsOn: ['otherField'], // See DependsOn below
|
|
||||||
mode: 'basic', // 'basic' | 'advanced' | 'both' | 'trigger'
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**SubBlock Types:** `short-input`, `long-input`, `dropdown`, `code`, `switch`, `slider`, `oauth-input`, `channel-selector`, `user-selector`, `file-upload`, etc.
|
|
||||||
|
|
||||||
### `condition` - Show/hide based on another field
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Show when operation === 'send'
|
|
||||||
condition: { field: 'operation', value: 'send' }
|
|
||||||
|
|
||||||
// Show when operation is 'send' OR 'read'
|
|
||||||
condition: { field: 'operation', value: ['send', 'read'] }
|
|
||||||
|
|
||||||
// Show when operation !== 'send'
|
|
||||||
condition: { field: 'operation', value: 'send', not: true }
|
|
||||||
|
|
||||||
// Complex: NOT in list AND another condition
|
|
||||||
condition: {
|
|
||||||
field: 'operation',
|
|
||||||
value: ['list_channels', 'list_users'],
|
|
||||||
not: true,
|
|
||||||
and: { field: 'destinationType', value: 'dm', not: true }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `required` - Field validation
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Always required
|
|
||||||
required: true
|
|
||||||
|
|
||||||
// Conditionally required (same syntax as condition)
|
|
||||||
required: { field: 'operation', value: 'send' }
|
|
||||||
```
|
|
||||||
|
|
||||||
### `dependsOn` - Clear field when dependencies change
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Clear when credential changes
|
|
||||||
dependsOn: ['credential']
|
|
||||||
|
|
||||||
// Clear when authMethod changes AND (credential OR botToken) changes
|
|
||||||
dependsOn: { all: ['authMethod'], any: ['credential', 'botToken'] }
|
|
||||||
```
|
|
||||||
|
|
||||||
### `mode` - When to show field
|
|
||||||
|
|
||||||
- `'basic'` - Only in basic mode (default UI)
|
|
||||||
- `'advanced'` - Only in advanced mode (manual input)
|
|
||||||
- `'both'` - Show in both modes (default)
|
|
||||||
- `'trigger'` - Only when block is used as trigger
|
|
||||||
|
|
||||||
**Register in `blocks/registry.ts`:**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { {Service}Block } from '@/blocks/blocks/{service}'
|
|
||||||
// Add to registry object (alphabetically)
|
|
||||||
{service}: {Service}Block,
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3. Icon (`components/icons.tsx`)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export function {Service}Icon(props: SVGProps<SVGSVGElement>) {
|
|
||||||
return (
|
|
||||||
<svg {...props} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
{/* SVG path from service's brand assets */}
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 4. Trigger (`triggers/{service}/`) - Optional
|
|
||||||
|
|
||||||
```
|
|
||||||
triggers/{service}/
|
|
||||||
├── index.ts # Export all triggers
|
|
||||||
├── webhook.ts # Webhook handler
|
|
||||||
├── utils.ts # Shared utilities
|
|
||||||
└── {event}.ts # Specific event handlers
|
|
||||||
```
|
|
||||||
|
|
||||||
**Register in `triggers/registry.ts`:**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { {service}WebhookTrigger } from '@/triggers/{service}'
|
|
||||||
// Add to TRIGGER_REGISTRY
|
|
||||||
{service}_webhook: {service}WebhookTrigger,
|
|
||||||
```
|
|
||||||
|
|
||||||
## Checklist
|
|
||||||
|
|
||||||
- [ ] Look up API docs for the service
|
|
||||||
- [ ] Create `tools/{service}/types.ts` with proper types
|
|
||||||
- [ ] Create tool files for each operation
|
|
||||||
- [ ] Create `tools/{service}/index.ts` barrel export
|
|
||||||
- [ ] Register tools in `tools/registry.ts`
|
|
||||||
- [ ] Add icon to `components/icons.tsx`
|
|
||||||
- [ ] Create block in `blocks/blocks/{service}.ts`
|
|
||||||
- [ ] Register block in `blocks/registry.ts`
|
|
||||||
- [ ] (Optional) Create triggers in `triggers/{service}/`
|
|
||||||
- [ ] (Optional) Register triggers in `triggers/registry.ts`
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
---
|
|
||||||
paths:
|
|
||||||
- "apps/sim/hooks/queries/**/*.ts"
|
|
||||||
---
|
|
||||||
|
|
||||||
# React Query Patterns
|
|
||||||
|
|
||||||
All React Query hooks live in `hooks/queries/`.
|
|
||||||
|
|
||||||
## Query Key Factory
|
|
||||||
|
|
||||||
Every query file defines a keys factory:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export const entityKeys = {
|
|
||||||
all: ['entity'] as const,
|
|
||||||
list: (workspaceId?: string) => [...entityKeys.all, 'list', workspaceId ?? ''] as const,
|
|
||||||
detail: (id?: string) => [...entityKeys.all, 'detail', id ?? ''] as const,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## File Structure
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// 1. Query keys factory
|
|
||||||
// 2. Types (if needed)
|
|
||||||
// 3. Private fetch functions
|
|
||||||
// 4. Exported hooks
|
|
||||||
```
|
|
||||||
|
|
||||||
## Query Hook
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export function useEntityList(workspaceId?: string, options?: { enabled?: boolean }) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: entityKeys.list(workspaceId),
|
|
||||||
queryFn: () => fetchEntities(workspaceId as string),
|
|
||||||
enabled: Boolean(workspaceId) && (options?.enabled ?? true),
|
|
||||||
staleTime: 60 * 1000,
|
|
||||||
placeholderData: keepPreviousData,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Mutation Hook
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export function useCreateEntity() {
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async (variables) => { /* fetch POST */ },
|
|
||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: entityKeys.all }),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Optimistic Updates
|
|
||||||
|
|
||||||
For optimistic mutations syncing with Zustand, use `createOptimisticMutationHandlers` from `@/hooks/queries/utils/optimistic-mutation`.
|
|
||||||
|
|
||||||
## Naming
|
|
||||||
|
|
||||||
- **Keys**: `entityKeys`
|
|
||||||
- **Query hooks**: `useEntity`, `useEntityList`
|
|
||||||
- **Mutation hooks**: `useCreateEntity`, `useUpdateEntity`
|
|
||||||
- **Fetch functions**: `fetchEntity` (private)
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
---
|
|
||||||
paths:
|
|
||||||
- "apps/sim/**/store.ts"
|
|
||||||
- "apps/sim/**/stores/**/*.ts"
|
|
||||||
---
|
|
||||||
|
|
||||||
# Zustand Store Patterns
|
|
||||||
|
|
||||||
Stores live in `stores/`. Complex stores split into `store.ts` + `types.ts`.
|
|
||||||
|
|
||||||
## Basic Store
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { create } from 'zustand'
|
|
||||||
import { devtools } from 'zustand/middleware'
|
|
||||||
import type { FeatureState } from '@/stores/feature/types'
|
|
||||||
|
|
||||||
const initialState = { items: [] as Item[], activeId: null as string | null }
|
|
||||||
|
|
||||||
export const useFeatureStore = create<FeatureState>()(
|
|
||||||
devtools(
|
|
||||||
(set, get) => ({
|
|
||||||
...initialState,
|
|
||||||
setItems: (items) => set({ items }),
|
|
||||||
addItem: (item) => set((state) => ({ items: [...state.items, item] })),
|
|
||||||
reset: () => set(initialState),
|
|
||||||
}),
|
|
||||||
{ name: 'feature-store' }
|
|
||||||
)
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Persisted Store
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { create } from 'zustand'
|
|
||||||
import { persist } from 'zustand/middleware'
|
|
||||||
|
|
||||||
export const useFeatureStore = create<FeatureState>()(
|
|
||||||
persist(
|
|
||||||
(set) => ({
|
|
||||||
width: 300,
|
|
||||||
setWidth: (width) => set({ width }),
|
|
||||||
_hasHydrated: false,
|
|
||||||
setHasHydrated: (v) => set({ _hasHydrated: v }),
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
name: 'feature-state',
|
|
||||||
partialize: (state) => ({ width: state.width }),
|
|
||||||
onRehydrateStorage: () => (state) => state?.setHasHydrated(true),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Rules
|
|
||||||
|
|
||||||
1. Use `devtools` middleware (named stores)
|
|
||||||
2. Use `persist` only when data should survive reload
|
|
||||||
3. `partialize` to persist only necessary state
|
|
||||||
4. `_hasHydrated` pattern for persisted stores needing hydration tracking
|
|
||||||
5. Immutable updates only
|
|
||||||
6. `set((state) => ...)` when depending on previous state
|
|
||||||
7. Provide `reset()` action
|
|
||||||
|
|
||||||
## Outside React
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const items = useFeatureStore.getState().items
|
|
||||||
useFeatureStore.setState({ items: newItems })
|
|
||||||
```
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
---
|
|
||||||
paths:
|
|
||||||
- "apps/sim/**/*.tsx"
|
|
||||||
- "apps/sim/**/*.css"
|
|
||||||
---
|
|
||||||
|
|
||||||
# Styling Rules
|
|
||||||
|
|
||||||
## Tailwind
|
|
||||||
|
|
||||||
1. **No inline styles** - Use Tailwind classes
|
|
||||||
2. **No duplicate dark classes** - Skip `dark:` when value matches light mode
|
|
||||||
3. **Exact values** - `text-[14px]`, `h-[26px]`
|
|
||||||
4. **Transitions** - `transition-colors` for interactive states
|
|
||||||
|
|
||||||
## Conditional Classes
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
|
|
||||||
<div className={cn(
|
|
||||||
'base-classes',
|
|
||||||
isActive && 'active-classes',
|
|
||||||
disabled ? 'opacity-60' : 'hover:bg-accent'
|
|
||||||
)} />
|
|
||||||
```
|
|
||||||
|
|
||||||
## CSS Variables
|
|
||||||
|
|
||||||
For dynamic values (widths, heights) synced with stores:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// In store
|
|
||||||
setWidth: (width) => {
|
|
||||||
set({ width })
|
|
||||||
document.documentElement.style.setProperty('--sidebar-width', `${width}px`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// In component
|
|
||||||
<aside style={{ width: 'var(--sidebar-width)' }} />
|
|
||||||
```
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
---
|
|
||||||
paths:
|
|
||||||
- "apps/sim/**/*.test.ts"
|
|
||||||
- "apps/sim/**/*.test.tsx"
|
|
||||||
---
|
|
||||||
|
|
||||||
# Testing Patterns
|
|
||||||
|
|
||||||
Use Vitest. Test files: `feature.ts` → `feature.test.ts`
|
|
||||||
|
|
||||||
## Structure
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
/**
|
|
||||||
* @vitest-environment node
|
|
||||||
*/
|
|
||||||
import { databaseMock, loggerMock } from '@sim/testing'
|
|
||||||
import { describe, expect, it, vi } from 'vitest'
|
|
||||||
|
|
||||||
vi.mock('@sim/db', () => databaseMock)
|
|
||||||
vi.mock('@sim/logger', () => loggerMock)
|
|
||||||
|
|
||||||
import { myFunction } from '@/lib/feature'
|
|
||||||
|
|
||||||
describe('myFunction', () => {
|
|
||||||
beforeEach(() => vi.clearAllMocks())
|
|
||||||
it.concurrent('isolated tests run in parallel', () => { ... })
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
## @sim/testing Package
|
|
||||||
|
|
||||||
Always prefer over local mocks.
|
|
||||||
|
|
||||||
| Category | Utilities |
|
|
||||||
|----------|-----------|
|
|
||||||
| **Mocks** | `loggerMock`, `databaseMock`, `setupGlobalFetchMock()` |
|
|
||||||
| **Factories** | `createSession()`, `createWorkflowRecord()`, `createBlock()`, `createExecutorContext()` |
|
|
||||||
| **Builders** | `WorkflowBuilder`, `ExecutionContextBuilder` |
|
|
||||||
| **Assertions** | `expectWorkflowAccessGranted()`, `expectBlockExecuted()` |
|
|
||||||
|
|
||||||
## Rules
|
|
||||||
|
|
||||||
1. `@vitest-environment node` directive at file top
|
|
||||||
2. `vi.mock()` calls before importing mocked modules
|
|
||||||
3. `@sim/testing` utilities over local mocks
|
|
||||||
4. `it.concurrent` for isolated tests (no shared mutable state)
|
|
||||||
5. `beforeEach(() => vi.clearAllMocks())` to reset state
|
|
||||||
|
|
||||||
## Hoisted Mocks
|
|
||||||
|
|
||||||
For mutable mock references:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const mockFn = vi.hoisted(() => vi.fn())
|
|
||||||
vi.mock('@/lib/module', () => ({ myFunction: mockFn }))
|
|
||||||
mockFn.mockResolvedValue({ data: 'test' })
|
|
||||||
```
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
---
|
|
||||||
paths:
|
|
||||||
- "apps/sim/**/*.ts"
|
|
||||||
- "apps/sim/**/*.tsx"
|
|
||||||
---
|
|
||||||
|
|
||||||
# TypeScript Rules
|
|
||||||
|
|
||||||
1. **No `any`** - Use proper types or `unknown` with type guards
|
|
||||||
2. **Props interface** - Always define for components
|
|
||||||
3. **Const assertions** - `as const` for constant objects/arrays
|
|
||||||
4. **Ref types** - Explicit: `useRef<HTMLDivElement>(null)`
|
|
||||||
5. **Type imports** - `import type { X }` for type-only imports
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// ✗ Bad
|
|
||||||
const handleClick = (e: any) => {}
|
|
||||||
|
|
||||||
// ✓ Good
|
|
||||||
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {}
|
|
||||||
```
|
|
||||||
@@ -8,7 +8,7 @@ alwaysApply: true
|
|||||||
You are a professional software engineer. All code must follow best practices: accurate, readable, clean, and efficient.
|
You are a professional software engineer. All code must follow best practices: accurate, readable, clean, and efficient.
|
||||||
|
|
||||||
## Logging
|
## Logging
|
||||||
Import `createLogger` from `@sim/logger`. Use `logger.info`, `logger.warn`, `logger.error` instead of `console.log`.
|
Import `createLogger` from `sim/logger`. Use `logger.info`, `logger.warn`, `logger.error` instead of `console.log`.
|
||||||
|
|
||||||
## Comments
|
## Comments
|
||||||
Use TSDoc for documentation. No `====` separators. No non-TSDoc comments.
|
Use TSDoc for documentation. No `====` separators. No non-TSDoc comments.
|
||||||
|
|||||||
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
@@ -27,9 +27,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Extract version from commit message
|
- name: Extract version from commit message
|
||||||
id: extract
|
id: extract
|
||||||
env:
|
|
||||||
COMMIT_MSG: ${{ github.event.head_commit.message }}
|
|
||||||
run: |
|
run: |
|
||||||
|
COMMIT_MSG="${{ github.event.head_commit.message }}"
|
||||||
# Only tag versions on main branch
|
# Only tag versions on main branch
|
||||||
if [ "${{ github.ref }}" = "refs/heads/main" ] && [[ "$COMMIT_MSG" =~ ^(v[0-9]+\.[0-9]+\.[0-9]+): ]]; then
|
if [ "${{ github.ref }}" = "refs/heads/main" ] && [[ "$COMMIT_MSG" =~ ^(v[0-9]+\.[0-9]+\.[0-9]+): ]]; then
|
||||||
VERSION="${BASH_REMATCH[1]}"
|
VERSION="${BASH_REMATCH[1]}"
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://deepwiki.com/simstudioai/sim" target="_blank" rel="noopener noreferrer"><img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki"></a> <a href="https://cursor.com/link/prompt?text=Help%20me%20set%20up%20Sim%20locally.%20Follow%20these%20steps%3A%0A%0A1.%20First%2C%20verify%20Docker%20is%20installed%20and%20running%3A%0A%20%20%20docker%20--version%0A%20%20%20docker%20info%0A%0A2.%20Clone%20the%20repository%3A%0A%20%20%20git%20clone%20https%3A%2F%2Fgithub.com%2Fsimstudioai%2Fsim.git%0A%20%20%20cd%20sim%0A%0A3.%20Start%20the%20services%20with%20Docker%20Compose%3A%0A%20%20%20docker%20compose%20-f%20docker-compose.prod.yml%20up%20-d%0A%0A4.%20Wait%20for%20all%20containers%20to%20be%20healthy%20(this%20may%20take%201-2%20minutes)%3A%0A%20%20%20docker%20compose%20-f%20docker-compose.prod.yml%20ps%0A%0A5.%20Verify%20the%20app%20is%20accessible%20at%20http%3A%2F%2Flocalhost%3A3000%0A%0AIf%20there%20are%20any%20errors%2C%20help%20me%20troubleshoot%20them.%20Common%20issues%3A%0A-%20Port%203000%2C%203002%2C%20or%205432%20already%20in%20use%0A-%20Docker%20not%20running%0A-%20Insufficient%20memory%20(needs%2012GB%2B%20RAM)%0A%0AFor%20local%20AI%20models%20with%20Ollama%2C%20use%20this%20instead%20of%20step%203%3A%0A%20%20%20docker%20compose%20-f%20docker-compose.ollama.yml%20--profile%20setup%20up%20-d"><img src="https://img.shields.io/badge/Set%20Up%20with-Cursor-000000?logo=cursor&logoColor=white" alt="Set Up with Cursor"></a>
|
<a href="https://deepwiki.com/simstudioai/sim" target="_blank" rel="noopener noreferrer"><img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki"></a> <a href="https://cursor.com/link/prompt?text=Help%20me%20set%20up%20Sim%20Studio%20locally.%20Follow%20these%20steps%3A%0A%0A1.%20First%2C%20verify%20Docker%20is%20installed%20and%20running%3A%0A%20%20%20docker%20--version%0A%20%20%20docker%20info%0A%0A2.%20Clone%20the%20repository%3A%0A%20%20%20git%20clone%20https%3A%2F%2Fgithub.com%2Fsimstudioai%2Fsim.git%0A%20%20%20cd%20sim%0A%0A3.%20Start%20the%20services%20with%20Docker%20Compose%3A%0A%20%20%20docker%20compose%20-f%20docker-compose.prod.yml%20up%20-d%0A%0A4.%20Wait%20for%20all%20containers%20to%20be%20healthy%20(this%20may%20take%201-2%20minutes)%3A%0A%20%20%20docker%20compose%20-f%20docker-compose.prod.yml%20ps%0A%0A5.%20Verify%20the%20app%20is%20accessible%20at%20http%3A%2F%2Flocalhost%3A3000%0A%0AIf%20there%20are%20any%20errors%2C%20help%20me%20troubleshoot%20them.%20Common%20issues%3A%0A-%20Port%203000%2C%203002%2C%20or%205432%20already%20in%20use%0A-%20Docker%20not%20running%0A-%20Insufficient%20memory%20(needs%2012GB%2B%20RAM)%0A%0AFor%20local%20AI%20models%20with%20Ollama%2C%20use%20this%20instead%20of%20step%203%3A%0A%20%20%20docker%20compose%20-f%20docker-compose.ollama.yml%20--profile%20setup%20up%20-d"><img src="https://img.shields.io/badge/Set%20Up%20with-Cursor-000000?logo=cursor&logoColor=white" alt="Set Up with Cursor"></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
### Build Workflows with Ease
|
### Build Workflows with Ease
|
||||||
|
|||||||
@@ -86,112 +86,27 @@ export async function GET(request: NextRequest) {
|
|||||||
)
|
)
|
||||||
.limit(candidateLimit)
|
.limit(candidateLimit)
|
||||||
|
|
||||||
const knownLocales = ['en', 'es', 'fr', 'de', 'ja', 'zh']
|
const seenIds = new Set<string>()
|
||||||
|
const mergedResults = []
|
||||||
|
|
||||||
const vectorRankMap = new Map<string, number>()
|
for (let i = 0; i < Math.max(vectorResults.length, keywordResults.length); i++) {
|
||||||
vectorResults.forEach((r, idx) => vectorRankMap.set(r.chunkId, idx + 1))
|
if (i < vectorResults.length && !seenIds.has(vectorResults[i].chunkId)) {
|
||||||
|
mergedResults.push(vectorResults[i])
|
||||||
const keywordRankMap = new Map<string, number>()
|
seenIds.add(vectorResults[i].chunkId)
|
||||||
keywordResults.forEach((r, idx) => keywordRankMap.set(r.chunkId, idx + 1))
|
}
|
||||||
|
if (i < keywordResults.length && !seenIds.has(keywordResults[i].chunkId)) {
|
||||||
const allChunkIds = new Set([
|
mergedResults.push(keywordResults[i])
|
||||||
...vectorResults.map((r) => r.chunkId),
|
seenIds.add(keywordResults[i].chunkId)
|
||||||
...keywordResults.map((r) => r.chunkId),
|
|
||||||
])
|
|
||||||
|
|
||||||
const k = 60
|
|
||||||
type ResultWithRRF = (typeof vectorResults)[0] & { rrfScore: number }
|
|
||||||
const scoredResults: ResultWithRRF[] = []
|
|
||||||
|
|
||||||
for (const chunkId of allChunkIds) {
|
|
||||||
const vectorRank = vectorRankMap.get(chunkId) ?? Number.POSITIVE_INFINITY
|
|
||||||
const keywordRank = keywordRankMap.get(chunkId) ?? Number.POSITIVE_INFINITY
|
|
||||||
|
|
||||||
const rrfScore = 1 / (k + vectorRank) + 1 / (k + keywordRank)
|
|
||||||
|
|
||||||
const result =
|
|
||||||
vectorResults.find((r) => r.chunkId === chunkId) ||
|
|
||||||
keywordResults.find((r) => r.chunkId === chunkId)
|
|
||||||
|
|
||||||
if (result) {
|
|
||||||
scoredResults.push({ ...result, rrfScore })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
scoredResults.sort((a, b) => b.rrfScore - a.rrfScore)
|
const filteredResults = mergedResults.slice(0, limit)
|
||||||
|
const searchResults = filteredResults.map((result) => {
|
||||||
const localeFilteredResults = scoredResults.filter((result) => {
|
|
||||||
const firstPart = result.sourceDocument.split('/')[0]
|
|
||||||
if (knownLocales.includes(firstPart)) {
|
|
||||||
return firstPart === locale
|
|
||||||
}
|
|
||||||
return locale === 'en'
|
|
||||||
})
|
|
||||||
|
|
||||||
const queryLower = query.toLowerCase()
|
|
||||||
const getTitleBoost = (result: ResultWithRRF): number => {
|
|
||||||
const fileName = result.sourceDocument
|
|
||||||
.replace('.mdx', '')
|
|
||||||
.split('/')
|
|
||||||
.pop()
|
|
||||||
?.toLowerCase()
|
|
||||||
?.replace(/_/g, ' ')
|
|
||||||
|
|
||||||
if (fileName === queryLower) return 0.01
|
|
||||||
if (fileName?.includes(queryLower)) return 0.005
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
localeFilteredResults.sort((a, b) => {
|
|
||||||
return b.rrfScore + getTitleBoost(b) - (a.rrfScore + getTitleBoost(a))
|
|
||||||
})
|
|
||||||
|
|
||||||
const pageMap = new Map<string, ResultWithRRF>()
|
|
||||||
|
|
||||||
for (const result of localeFilteredResults) {
|
|
||||||
const pageKey = result.sourceDocument
|
|
||||||
const existing = pageMap.get(pageKey)
|
|
||||||
|
|
||||||
if (!existing || result.rrfScore > existing.rrfScore) {
|
|
||||||
pageMap.set(pageKey, result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const deduplicatedResults = Array.from(pageMap.values())
|
|
||||||
.sort((a, b) => b.rrfScore + getTitleBoost(b) - (a.rrfScore + getTitleBoost(a)))
|
|
||||||
.slice(0, limit)
|
|
||||||
|
|
||||||
const searchResults = deduplicatedResults.map((result) => {
|
|
||||||
const title = result.headerText || result.sourceDocument.replace('.mdx', '')
|
const title = result.headerText || result.sourceDocument.replace('.mdx', '')
|
||||||
|
|
||||||
const pathParts = result.sourceDocument
|
const pathParts = result.sourceDocument
|
||||||
.replace('.mdx', '')
|
.replace('.mdx', '')
|
||||||
.split('/')
|
.split('/')
|
||||||
.filter((part) => part !== 'index' && !knownLocales.includes(part))
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||||
.map((part) => {
|
|
||||||
return part
|
|
||||||
.replace(/_/g, ' ')
|
|
||||||
.split(' ')
|
|
||||||
.map((word) => {
|
|
||||||
const acronyms = [
|
|
||||||
'api',
|
|
||||||
'mcp',
|
|
||||||
'sdk',
|
|
||||||
'url',
|
|
||||||
'http',
|
|
||||||
'json',
|
|
||||||
'xml',
|
|
||||||
'html',
|
|
||||||
'css',
|
|
||||||
'ai',
|
|
||||||
]
|
|
||||||
if (acronyms.includes(word.toLowerCase())) {
|
|
||||||
return word.toUpperCase()
|
|
||||||
}
|
|
||||||
return word.charAt(0).toUpperCase() + word.slice(1)
|
|
||||||
})
|
|
||||||
.join(' ')
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: result.chunkId,
|
id: result.chunkId,
|
||||||
|
|||||||
@@ -1739,12 +1739,12 @@ export function BrowserUseIcon(props: SVGProps<SVGSVGElement>) {
|
|||||||
{...props}
|
{...props}
|
||||||
version='1.0'
|
version='1.0'
|
||||||
xmlns='http://www.w3.org/2000/svg'
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
width='28'
|
width='150pt'
|
||||||
height='28'
|
height='150pt'
|
||||||
viewBox='0 0 150 150'
|
viewBox='0 0 150 150'
|
||||||
preserveAspectRatio='xMidYMid meet'
|
preserveAspectRatio='xMidYMid meet'
|
||||||
>
|
>
|
||||||
<g transform='translate(0,150) scale(0.05,-0.05)' fill='currentColor' stroke='none'>
|
<g transform='translate(0,150) scale(0.05,-0.05)' fill='#000000' stroke='none'>
|
||||||
<path
|
<path
|
||||||
d='M786 2713 c-184 -61 -353 -217 -439 -405 -76 -165 -65 -539 19 -666
|
d='M786 2713 c-184 -61 -353 -217 -439 -405 -76 -165 -65 -539 19 -666
|
||||||
l57 -85 -48 -124 c-203 -517 -79 -930 346 -1155 159 -85 441 -71 585 28 l111
|
l57 -85 -48 -124 c-203 -517 -79 -930 346 -1155 159 -85 441 -71 585 28 l111
|
||||||
@@ -4093,23 +4093,6 @@ export function SQSIcon(props: SVGProps<SVGSVGElement>) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TextractIcon(props: SVGProps<SVGSVGElement>) {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
{...props}
|
|
||||||
viewBox='10 14 60 52'
|
|
||||||
version='1.1'
|
|
||||||
xmlns='http://www.w3.org/2000/svg'
|
|
||||||
xmlnsXlink='http://www.w3.org/1999/xlink'
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d='M22.0624102,50 C24.3763895,53.603 28.4103535,56 33.0003125,56 C40.1672485,56 45.9991964,50.168 45.9991964,43 C45.9991964,35.832 40.1672485,30 33.0003125,30 C27.6033607,30 22.9664021,33.307 21.0024196,38 L23.2143999,38 C25.0393836,34.444 28.7363506,32 33.0003125,32 C39.0652583,32 43.9992143,36.935 43.9992143,43 C43.9992143,49.065 39.0652583,54 33.0003125,54 C29.5913429,54 26.5413702,52.441 24.5213882,50 L22.0624102,50 Z M37.0002768,45 L37.0002768,43 L41.9992321,43 C41.9992321,38.038 37.9622682,34 33.0003125,34 C28.0373568,34 23.9993929,38.038 23.9993929,43 L28.9993482,43 L28.9993482,45 L24.2313908,45 C25.1443826,49.002 28.7253507,52 33.0003125,52 C35.1362934,52 37.0992759,51.249 38.6442621,50 L34.0003036,50 L34.0003036,48 L40.4782457,48 C41.0812403,47.102 41.5202364,46.087 41.7682342,45 L37.0002768,45 Z M21.0024196,48 L23.2143999,48 C22.4434068,46.498 22.0004107,44.801 22.0004107,43 C22.0004107,41.959 22.1554093,40.955 22.4264069,40 L20.3634253,40 C20.1344274,40.965 19.9994286,41.966 19.9994286,43 C19.9994286,44.771 20.3584254,46.46 21.0024196,48 L21.0024196,48 Z M19.7434309,50 L17.0004554,50 L17.0004554,48 L18.8744386,48 C18.5344417,47.04 18.2894438,46.038 18.1494451,45 L15.4144695,45 L16.707458,46.293 L15.2924706,47.707 L12.2924974,44.707 C11.9025009,44.316 11.9025009,43.684 12.2924974,43.293 L15.2924706,40.293 L16.707458,41.707 L15.4144695,43 L18.0004464,43 C18.0004464,41.973 18.1044455,40.97 18.3024437,40 L17.0004554,40 L17.0004554,38 L18.8744386,38 C20.9404202,32.184 26.4833707,28 33.0003125,28 C37.427273,28 41.4002375,29.939 44.148213,33 L59.0000804,33 L59.0000804,35 L45.6661994,35 C47.1351863,37.318 47.9991786,40.058 47.9991786,43 L59.0000804,43 L59.0000804,45 L47.8501799,45 C46.8681887,52.327 40.5912447,58 33.0003125,58 C27.2563638,58 22.2624084,54.752 19.7434309,50 L19.7434309,50 Z M37.0002768,39 C37.0002768,38.448 36.5522808,38 36.0002857,38 L29.9993482,38 C29.4473442,38 28.9993482,38.448 28.9993482,39 L28.9993482,41 L31.0003304,41 L31.0003304,40 L32.0003214,40 L32.0003214,43 L31.0003304,43 L31.0003304,45 L35.0002946,45 L35.0002946,43 L34.0003036,43 L34.0003036,40 L35.0002946,40 L35.0002946,41 L37.0002768,41 L37.0002768,39 Z M49.0001696,40 L59.0000804,40 L59.0000804,38 L49.0001696,38 L49.0001696,40 Z M49.0001696,50 L59.0000804,50 L59.0000804,48 L49.0001696,48 L49.0001696,50 Z M57.0000982,27 L60.5850662,27 L57.0000982,23.414 L57.0000982,27 Z M63.7070383,27.293 C63.8940367,27.48 64.0000357,27.735 64.0000357,28 L64.0000357,63 C64.0000357,63.552 63.5520397,64 63.0000446,64 L32.0003304,64 C31.4473264,64 31.0003304,63.552 31.0003304,63 L31.0003304,59 L33.0003125,59 L33.0003125,62 L62.0000536,62 L62.0000536,29 L56.0001071,29 C55.4471121,29 55.0001161,28.552 55.0001161,28 L55.0001161,22 L33.0003125,22 L33.0003125,27 L31.0003304,27 L31.0003304,21 C31.0003304,20.448 31.4473264,20 32.0003304,20 L56.0001071,20 C56.2651048,20 56.5191025,20.105 56.7071008,20.293 L63.7070383,27.293 Z M68,24.166 L68,61 C68,61.552 67.552004,62 67.0000089,62 L65.0000268,62 L65.0000268,60 L66.0000179,60 L66.0000179,24.612 L58.6170838,18 L36.0002857,18 L36.0002857,19 L34.0003036,19 L34.0003036,17 C34.0003036,16.448 34.4472996,16 35.0003036,16 L59.0000804,16 C59.2460782,16 59.483076,16.091 59.6660744,16.255 L67.666003,23.42 C67.8780011,23.61 68,23.881 68,24.166 L68,24.166 Z'
|
|
||||||
fill='currentColor'
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function McpIcon(props: SVGProps<SVGSVGElement>) {
|
export function McpIcon(props: SVGProps<SVGSVGElement>) {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
|
|||||||
@@ -110,7 +110,6 @@ import {
|
|||||||
SupabaseIcon,
|
SupabaseIcon,
|
||||||
TavilyIcon,
|
TavilyIcon,
|
||||||
TelegramIcon,
|
TelegramIcon,
|
||||||
TextractIcon,
|
|
||||||
TinybirdIcon,
|
TinybirdIcon,
|
||||||
TranslateIcon,
|
TranslateIcon,
|
||||||
TrelloIcon,
|
TrelloIcon,
|
||||||
@@ -144,7 +143,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
|||||||
calendly: CalendlyIcon,
|
calendly: CalendlyIcon,
|
||||||
circleback: CirclebackIcon,
|
circleback: CirclebackIcon,
|
||||||
clay: ClayIcon,
|
clay: ClayIcon,
|
||||||
confluence_v2: ConfluenceIcon,
|
confluence: ConfluenceIcon,
|
||||||
cursor_v2: CursorIcon,
|
cursor_v2: CursorIcon,
|
||||||
datadog: DatadogIcon,
|
datadog: DatadogIcon,
|
||||||
discord: DiscordIcon,
|
discord: DiscordIcon,
|
||||||
@@ -154,7 +153,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
|||||||
elasticsearch: ElasticsearchIcon,
|
elasticsearch: ElasticsearchIcon,
|
||||||
elevenlabs: ElevenLabsIcon,
|
elevenlabs: ElevenLabsIcon,
|
||||||
exa: ExaAIIcon,
|
exa: ExaAIIcon,
|
||||||
file_v2: DocumentIcon,
|
file: DocumentIcon,
|
||||||
firecrawl: FirecrawlIcon,
|
firecrawl: FirecrawlIcon,
|
||||||
fireflies: FirefliesIcon,
|
fireflies: FirefliesIcon,
|
||||||
github_v2: GithubIcon,
|
github_v2: GithubIcon,
|
||||||
@@ -196,7 +195,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
|||||||
microsoft_excel_v2: MicrosoftExcelIcon,
|
microsoft_excel_v2: MicrosoftExcelIcon,
|
||||||
microsoft_planner: MicrosoftPlannerIcon,
|
microsoft_planner: MicrosoftPlannerIcon,
|
||||||
microsoft_teams: MicrosoftTeamsIcon,
|
microsoft_teams: MicrosoftTeamsIcon,
|
||||||
mistral_parse_v2: MistralIcon,
|
mistral_parse: MistralIcon,
|
||||||
mongodb: MongoDBIcon,
|
mongodb: MongoDBIcon,
|
||||||
mysql: MySQLIcon,
|
mysql: MySQLIcon,
|
||||||
neo4j: Neo4jIcon,
|
neo4j: Neo4jIcon,
|
||||||
@@ -238,7 +237,6 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
|||||||
supabase: SupabaseIcon,
|
supabase: SupabaseIcon,
|
||||||
tavily: TavilyIcon,
|
tavily: TavilyIcon,
|
||||||
telegram: TelegramIcon,
|
telegram: TelegramIcon,
|
||||||
textract: TextractIcon,
|
|
||||||
tinybird: TinybirdIcon,
|
tinybird: TinybirdIcon,
|
||||||
translate: TranslateIcon,
|
translate: TranslateIcon,
|
||||||
trello: TrelloIcon,
|
trello: TrelloIcon,
|
||||||
@@ -246,7 +244,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
|||||||
twilio_sms: TwilioIcon,
|
twilio_sms: TwilioIcon,
|
||||||
twilio_voice: TwilioIcon,
|
twilio_voice: TwilioIcon,
|
||||||
typeform: TypeformIcon,
|
typeform: TypeformIcon,
|
||||||
video_generator_v2: VideoIcon,
|
video_generator: VideoIcon,
|
||||||
vision: EyeIcon,
|
vision: EyeIcon,
|
||||||
wealthbox: WealthboxIcon,
|
wealthbox: WealthboxIcon,
|
||||||
webflow: WebflowIcon,
|
webflow: WebflowIcon,
|
||||||
|
|||||||
@@ -124,44 +124,11 @@ Choose between four types of loops:
|
|||||||
3. Drag other blocks inside the loop container
|
3. Drag other blocks inside the loop container
|
||||||
4. Connect the blocks as needed
|
4. Connect the blocks as needed
|
||||||
|
|
||||||
### Referencing Loop Data
|
### Accessing Results
|
||||||
|
|
||||||
There's an important distinction between referencing loop data from **inside** vs **outside** the loop:
|
After a loop completes, you can access aggregated results:
|
||||||
|
|
||||||
<Tabs items={['Inside the Loop', 'Outside the Loop']}>
|
- **`<loop.results>`**: Array of results from all loop iterations
|
||||||
<Tab>
|
|
||||||
**Inside the loop**, use `<loop.>` references to access the current iteration context:
|
|
||||||
|
|
||||||
- **`<loop.index>`**: Current iteration number (0-based)
|
|
||||||
- **`<loop.currentItem>`**: Current item being processed (forEach only)
|
|
||||||
- **`<loop.items>`**: Full collection being iterated (forEach only)
|
|
||||||
|
|
||||||
```
|
|
||||||
// Inside a Function block within the loop
|
|
||||||
const idx = <loop.index>; // 0, 1, 2, ...
|
|
||||||
const item = <loop.currentItem>; // Current item
|
|
||||||
```
|
|
||||||
|
|
||||||
<Callout type="info">
|
|
||||||
These references are only available for blocks **inside** the loop container. They give you access to the current iteration's context.
|
|
||||||
</Callout>
|
|
||||||
</Tab>
|
|
||||||
<Tab>
|
|
||||||
**Outside the loop** (after it completes), reference the loop block by its name to access aggregated results:
|
|
||||||
|
|
||||||
- **`<LoopBlockName.results>`**: Array of results from all iterations
|
|
||||||
|
|
||||||
```
|
|
||||||
// If your loop block is named "Process Items"
|
|
||||||
const allResults = <processitems.results>;
|
|
||||||
// Returns: [result1, result2, result3, ...]
|
|
||||||
```
|
|
||||||
|
|
||||||
<Callout type="info">
|
|
||||||
After the loop completes, use the loop's block name (not `loop.`) to access the collected results. The block name is normalized (lowercase, no spaces).
|
|
||||||
</Callout>
|
|
||||||
</Tab>
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
## Example Use Cases
|
## Example Use Cases
|
||||||
|
|
||||||
@@ -217,29 +184,28 @@ Variables (i=0) → Loop (While i<10) → Agent (Process) → Variables (i++)
|
|||||||
</ul>
|
</ul>
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab>
|
<Tab>
|
||||||
Available **inside** the loop only:
|
|
||||||
<ul className="list-disc space-y-2 pl-6">
|
<ul className="list-disc space-y-2 pl-6">
|
||||||
<li>
|
<li>
|
||||||
<strong>{"<loop.index>"}</strong>: Current iteration number (0-based)
|
<strong>loop.currentItem</strong>: Current item being processed
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>{"<loop.currentItem>"}</strong>: Current item being processed (forEach only)
|
<strong>loop.index</strong>: Current iteration number (0-based)
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>{"<loop.items>"}</strong>: Full collection (forEach only)
|
<strong>loop.items</strong>: Full collection (forEach loops)
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab>
|
<Tab>
|
||||||
<ul className="list-disc space-y-2 pl-6">
|
<ul className="list-disc space-y-2 pl-6">
|
||||||
<li>
|
<li>
|
||||||
<strong>{"<blockname.results>"}</strong>: Array of all iteration results (accessed via block name)
|
<strong>loop.results</strong>: Array of all iteration results
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>Structure</strong>: Results maintain iteration order
|
<strong>Structure</strong>: Results maintain iteration order
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>Access</strong>: Available in blocks after the loop completes
|
<strong>Access</strong>: Available in blocks after the loop
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</Tab>
|
</Tab>
|
||||||
|
|||||||
@@ -76,44 +76,11 @@ Choose between two types of parallel execution:
|
|||||||
3. Drag a single block inside the parallel container
|
3. Drag a single block inside the parallel container
|
||||||
4. Connect the block as needed
|
4. Connect the block as needed
|
||||||
|
|
||||||
### Referencing Parallel Data
|
### Accessing Results
|
||||||
|
|
||||||
There's an important distinction between referencing parallel data from **inside** vs **outside** the parallel block:
|
After a parallel block completes, you can access aggregated results:
|
||||||
|
|
||||||
<Tabs items={['Inside the Parallel', 'Outside the Parallel']}>
|
- **`<parallel.results>`**: Array of results from all parallel instances
|
||||||
<Tab>
|
|
||||||
**Inside the parallel**, use `<parallel.>` references to access the current instance context:
|
|
||||||
|
|
||||||
- **`<parallel.index>`**: Current instance number (0-based)
|
|
||||||
- **`<parallel.currentItem>`**: Item for this instance (collection-based only)
|
|
||||||
- **`<parallel.items>`**: Full collection being distributed (collection-based only)
|
|
||||||
|
|
||||||
```
|
|
||||||
// Inside a Function block within the parallel
|
|
||||||
const idx = <parallel.index>; // 0, 1, 2, ...
|
|
||||||
const item = <parallel.currentItem>; // This instance's item
|
|
||||||
```
|
|
||||||
|
|
||||||
<Callout type="info">
|
|
||||||
These references are only available for blocks **inside** the parallel container. They give you access to the current instance's context.
|
|
||||||
</Callout>
|
|
||||||
</Tab>
|
|
||||||
<Tab>
|
|
||||||
**Outside the parallel** (after it completes), reference the parallel block by its name to access aggregated results:
|
|
||||||
|
|
||||||
- **`<ParallelBlockName.results>`**: Array of results from all instances
|
|
||||||
|
|
||||||
```
|
|
||||||
// If your parallel block is named "Process Tasks"
|
|
||||||
const allResults = <processtasks.results>;
|
|
||||||
// Returns: [result1, result2, result3, ...]
|
|
||||||
```
|
|
||||||
|
|
||||||
<Callout type="info">
|
|
||||||
After the parallel completes, use the parallel's block name (not `parallel.`) to access the collected results. The block name is normalized (lowercase, no spaces).
|
|
||||||
</Callout>
|
|
||||||
</Tab>
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
## Example Use Cases
|
## Example Use Cases
|
||||||
|
|
||||||
@@ -131,11 +98,11 @@ Parallel (["gpt-4o", "claude-3.7-sonnet", "gemini-2.5-pro"]) → Agent → Evalu
|
|||||||
|
|
||||||
### Result Aggregation
|
### Result Aggregation
|
||||||
|
|
||||||
Results from all parallel instances are automatically collected and accessible via the block name:
|
Results from all parallel instances are automatically collected:
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// In a Function block after a parallel named "Process Tasks"
|
// In a Function block after the parallel
|
||||||
const allResults = <processtasks.results>;
|
const allResults = input.parallel.results;
|
||||||
// Returns: [result1, result2, result3, ...]
|
// Returns: [result1, result2, result3, ...]
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -191,26 +158,25 @@ Understanding when to use each:
|
|||||||
</ul>
|
</ul>
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab>
|
<Tab>
|
||||||
Available **inside** the parallel only:
|
|
||||||
<ul className="list-disc space-y-2 pl-6">
|
<ul className="list-disc space-y-2 pl-6">
|
||||||
<li>
|
<li>
|
||||||
<strong>{"<parallel.index>"}</strong>: Instance number (0-based)
|
<strong>parallel.currentItem</strong>: Item for this instance
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>{"<parallel.currentItem>"}</strong>: Item for this instance (collection-based only)
|
<strong>parallel.index</strong>: Instance number (0-based)
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>{"<parallel.items>"}</strong>: Full collection (collection-based only)
|
<strong>parallel.items</strong>: Full collection (collection-based)
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab>
|
<Tab>
|
||||||
<ul className="list-disc space-y-2 pl-6">
|
<ul className="list-disc space-y-2 pl-6">
|
||||||
<li>
|
<li>
|
||||||
<strong>{"<blockname.results>"}</strong>: Array of all instance results (accessed via block name)
|
<strong>parallel.results</strong>: Array of all instance results
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>Access</strong>: Available in blocks after the parallel completes
|
<strong>Access</strong>: Available in blocks after the parallel
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</Tab>
|
</Tab>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
|
|||||||
|
|
||||||
<BlockInfoCard
|
<BlockInfoCard
|
||||||
type="browser_use"
|
type="browser_use"
|
||||||
color="#181C1E"
|
color="#E0E0E0"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* MANUAL-CONTENT-START:intro */}
|
{/* MANUAL-CONTENT-START:intro */}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ description: Interact with Confluence
|
|||||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||||
|
|
||||||
<BlockInfoCard
|
<BlockInfoCard
|
||||||
type="confluence_v2"
|
type="confluence"
|
||||||
color="#E0E0E0"
|
color="#E0E0E0"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ description: Read and parse multiple files
|
|||||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||||
|
|
||||||
<BlockInfoCard
|
<BlockInfoCard
|
||||||
type="file_v2"
|
type="file"
|
||||||
color="#40916C"
|
color="#40916C"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ Parse one or more uploaded files or files from URLs (text, PDF, CSV, images, etc
|
|||||||
|
|
||||||
| Parameter | Type | Description |
|
| Parameter | Type | Description |
|
||||||
| --------- | ---- | ----------- |
|
| --------- | ---- | ----------- |
|
||||||
| `files` | array | Array of parsed files with content, metadata, and file properties |
|
| `files` | array | Array of parsed files |
|
||||||
| `combinedContent` | string | All file contents merged into a single text string |
|
| `combinedContent` | string | Combined content of all parsed files |
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -52,15 +52,6 @@ Read content from a Google Slides presentation
|
|||||||
| --------- | ---- | ----------- |
|
| --------- | ---- | ----------- |
|
||||||
| `slides` | json | Array of slides with their content |
|
| `slides` | json | Array of slides with their content |
|
||||||
| `metadata` | json | Presentation metadata including ID, title, and URL |
|
| `metadata` | json | Presentation metadata including ID, title, and URL |
|
||||||
| ↳ `presentationId` | string | The presentation ID |
|
|
||||||
| ↳ `title` | string | The presentation title |
|
|
||||||
| ↳ `pageSize` | object | Presentation page size |
|
|
||||||
| ↳ `width` | json | Page width as a Dimension object |
|
|
||||||
| ↳ `height` | json | Page height as a Dimension object |
|
|
||||||
| ↳ `width` | json | Page width as a Dimension object |
|
|
||||||
| ↳ `height` | json | Page height as a Dimension object |
|
|
||||||
| ↳ `mimeType` | string | The mime type of the presentation |
|
|
||||||
| ↳ `url` | string | URL to open the presentation |
|
|
||||||
|
|
||||||
### `google_slides_write`
|
### `google_slides_write`
|
||||||
|
|
||||||
@@ -80,10 +71,6 @@ Write or update content in a Google Slides presentation
|
|||||||
| --------- | ---- | ----------- |
|
| --------- | ---- | ----------- |
|
||||||
| `updatedContent` | boolean | Indicates if presentation content was updated successfully |
|
| `updatedContent` | boolean | Indicates if presentation content was updated successfully |
|
||||||
| `metadata` | json | Updated presentation metadata including ID, title, and URL |
|
| `metadata` | json | Updated presentation metadata including ID, title, and URL |
|
||||||
| ↳ `presentationId` | string | The presentation ID |
|
|
||||||
| ↳ `title` | string | The presentation title |
|
|
||||||
| ↳ `mimeType` | string | The mime type of the presentation |
|
|
||||||
| ↳ `url` | string | URL to open the presentation |
|
|
||||||
|
|
||||||
### `google_slides_create`
|
### `google_slides_create`
|
||||||
|
|
||||||
@@ -103,10 +90,6 @@ Create a new Google Slides presentation
|
|||||||
| Parameter | Type | Description |
|
| Parameter | Type | Description |
|
||||||
| --------- | ---- | ----------- |
|
| --------- | ---- | ----------- |
|
||||||
| `metadata` | json | Created presentation metadata including ID, title, and URL |
|
| `metadata` | json | Created presentation metadata including ID, title, and URL |
|
||||||
| ↳ `presentationId` | string | The presentation ID |
|
|
||||||
| ↳ `title` | string | The presentation title |
|
|
||||||
| ↳ `mimeType` | string | The mime type of the presentation |
|
|
||||||
| ↳ `url` | string | URL to open the presentation |
|
|
||||||
|
|
||||||
### `google_slides_replace_all_text`
|
### `google_slides_replace_all_text`
|
||||||
|
|
||||||
@@ -128,10 +111,6 @@ Find and replace all occurrences of text throughout a Google Slides presentation
|
|||||||
| --------- | ---- | ----------- |
|
| --------- | ---- | ----------- |
|
||||||
| `occurrencesChanged` | number | Number of text occurrences that were replaced |
|
| `occurrencesChanged` | number | Number of text occurrences that were replaced |
|
||||||
| `metadata` | json | Operation metadata including presentation ID and URL |
|
| `metadata` | json | Operation metadata including presentation ID and URL |
|
||||||
| ↳ `presentationId` | string | The presentation ID |
|
|
||||||
| ↳ `findText` | string | The text that was searched for |
|
|
||||||
| ↳ `replaceText` | string | The text that replaced the matches |
|
|
||||||
| ↳ `url` | string | URL to open the presentation |
|
|
||||||
|
|
||||||
### `google_slides_add_slide`
|
### `google_slides_add_slide`
|
||||||
|
|
||||||
@@ -152,10 +131,6 @@ Add a new slide to a Google Slides presentation with a specified layout
|
|||||||
| --------- | ---- | ----------- |
|
| --------- | ---- | ----------- |
|
||||||
| `slideId` | string | The object ID of the newly created slide |
|
| `slideId` | string | The object ID of the newly created slide |
|
||||||
| `metadata` | json | Operation metadata including presentation ID, layout, and URL |
|
| `metadata` | json | Operation metadata including presentation ID, layout, and URL |
|
||||||
| ↳ `presentationId` | string | The presentation ID |
|
|
||||||
| ↳ `layout` | string | The layout used for the new slide |
|
|
||||||
| ↳ `insertionIndex` | number | The zero-based index where the slide was inserted |
|
|
||||||
| ↳ `url` | string | URL to open the presentation |
|
|
||||||
|
|
||||||
### `google_slides_add_image`
|
### `google_slides_add_image`
|
||||||
|
|
||||||
@@ -179,10 +154,6 @@ Insert an image into a specific slide in a Google Slides presentation
|
|||||||
| --------- | ---- | ----------- |
|
| --------- | ---- | ----------- |
|
||||||
| `imageId` | string | The object ID of the newly created image |
|
| `imageId` | string | The object ID of the newly created image |
|
||||||
| `metadata` | json | Operation metadata including presentation ID and image URL |
|
| `metadata` | json | Operation metadata including presentation ID and image URL |
|
||||||
| ↳ `presentationId` | string | The presentation ID |
|
|
||||||
| ↳ `pageObjectId` | string | The page object ID where the image was inserted |
|
|
||||||
| ↳ `imageUrl` | string | The source image URL |
|
|
||||||
| ↳ `url` | string | URL to open the presentation |
|
|
||||||
|
|
||||||
### `google_slides_get_thumbnail`
|
### `google_slides_get_thumbnail`
|
||||||
|
|
||||||
@@ -205,10 +176,6 @@ Generate a thumbnail image of a specific slide in a Google Slides presentation
|
|||||||
| `width` | number | Width of the thumbnail in pixels |
|
| `width` | number | Width of the thumbnail in pixels |
|
||||||
| `height` | number | Height of the thumbnail in pixels |
|
| `height` | number | Height of the thumbnail in pixels |
|
||||||
| `metadata` | json | Operation metadata including presentation ID and page object ID |
|
| `metadata` | json | Operation metadata including presentation ID and page object ID |
|
||||||
| ↳ `presentationId` | string | The presentation ID |
|
|
||||||
| ↳ `pageObjectId` | string | The page object ID for the thumbnail |
|
|
||||||
| ↳ `thumbnailSize` | string | The requested thumbnail size |
|
|
||||||
| ↳ `mimeType` | string | The thumbnail MIME type |
|
|
||||||
|
|
||||||
### `google_slides_get_page`
|
### `google_slides_get_page`
|
||||||
|
|
||||||
|
|||||||
@@ -106,7 +106,6 @@
|
|||||||
"supabase",
|
"supabase",
|
||||||
"tavily",
|
"tavily",
|
||||||
"telegram",
|
"telegram",
|
||||||
"textract",
|
|
||||||
"tinybird",
|
"tinybird",
|
||||||
"translate",
|
"translate",
|
||||||
"trello",
|
"trello",
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ description: Extract text from PDF documents
|
|||||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||||
|
|
||||||
<BlockInfoCard
|
<BlockInfoCard
|
||||||
type="mistral_parse_v2"
|
type="mistral_parse"
|
||||||
color="#000000"
|
color="#000000"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -54,37 +54,18 @@ Parse PDF documents using Mistral OCR API
|
|||||||
|
|
||||||
| Parameter | Type | Description |
|
| Parameter | Type | Description |
|
||||||
| --------- | ---- | ----------- |
|
| --------- | ---- | ----------- |
|
||||||
| `pages` | array | Array of page objects from Mistral OCR |
|
| `success` | boolean | Whether the PDF was parsed successfully |
|
||||||
| ↳ `index` | number | Page index \(zero-based\) |
|
| `content` | string | Extracted content in the requested format \(markdown, text, or JSON\) |
|
||||||
| ↳ `markdown` | string | Extracted markdown content |
|
| `metadata` | object | Processing metadata including jobId, fileType, pageCount, and usage info |
|
||||||
| ↳ `images` | array | Images extracted from this page with bounding boxes |
|
| ↳ `jobId` | string | Unique job identifier |
|
||||||
| ↳ `id` | string | Image identifier \(e.g., img-0.jpeg\) |
|
| ↳ `fileType` | string | File type \(e.g., pdf\) |
|
||||||
| ↳ `top_left_x` | number | Top-left X coordinate in pixels |
|
| ↳ `fileName` | string | Original file name |
|
||||||
| ↳ `top_left_y` | number | Top-left Y coordinate in pixels |
|
| ↳ `source` | string | Source type \(url\) |
|
||||||
| ↳ `bottom_right_x` | number | Bottom-right X coordinate in pixels |
|
| ↳ `pageCount` | number | Number of pages processed |
|
||||||
| ↳ `bottom_right_y` | number | Bottom-right Y coordinate in pixels |
|
| ↳ `model` | string | Mistral model used |
|
||||||
| ↳ `image_base64` | string | Base64-encoded image data \(when include_image_base64=true\) |
|
| ↳ `resultType` | string | Output format \(markdown, text, json\) |
|
||||||
| ↳ `id` | string | Image identifier \(e.g., img-0.jpeg\) |
|
| ↳ `processedAt` | string | Processing timestamp |
|
||||||
| ↳ `top_left_x` | number | Top-left X coordinate in pixels |
|
| ↳ `sourceUrl` | string | Source URL if applicable |
|
||||||
| ↳ `top_left_y` | number | Top-left Y coordinate in pixels |
|
| ↳ `usageInfo` | object | Usage statistics from OCR processing |
|
||||||
| ↳ `bottom_right_x` | number | Bottom-right X coordinate in pixels |
|
|
||||||
| ↳ `bottom_right_y` | number | Bottom-right Y coordinate in pixels |
|
|
||||||
| ↳ `image_base64` | string | Base64-encoded image data \(when include_image_base64=true\) |
|
|
||||||
| ↳ `dimensions` | object | Page dimensions |
|
|
||||||
| ↳ `dpi` | number | Dots per inch |
|
|
||||||
| ↳ `height` | number | Page height in pixels |
|
|
||||||
| ↳ `width` | number | Page width in pixels |
|
|
||||||
| ↳ `dpi` | number | Dots per inch |
|
|
||||||
| ↳ `height` | number | Page height in pixels |
|
|
||||||
| ↳ `width` | number | Page width in pixels |
|
|
||||||
| ↳ `tables` | array | Extracted tables as HTML/markdown \(when table_format is set\). Referenced via placeholders like \[tbl-0.html\] |
|
|
||||||
| ↳ `hyperlinks` | array | Array of URL strings detected in the page \(e.g., \[ |
|
|
||||||
| ↳ `header` | string | Page header content \(when extract_header=true\) |
|
|
||||||
| ↳ `footer` | string | Page footer content \(when extract_footer=true\) |
|
|
||||||
| `model` | string | Mistral OCR model identifier \(e.g., mistral-ocr-latest\) |
|
|
||||||
| `usage_info` | object | Usage and processing statistics |
|
|
||||||
| ↳ `pages_processed` | number | Total number of pages processed |
|
|
||||||
| ↳ `doc_size_bytes` | number | Document file size in bytes |
|
|
||||||
| `document_annotation` | string | Structured annotation data as JSON string \(when applicable\) |
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -58,7 +58,6 @@ Upload a file to an AWS S3 bucket
|
|||||||
| Parameter | Type | Description |
|
| Parameter | Type | Description |
|
||||||
| --------- | ---- | ----------- |
|
| --------- | ---- | ----------- |
|
||||||
| `url` | string | URL of the uploaded S3 object |
|
| `url` | string | URL of the uploaded S3 object |
|
||||||
| `uri` | string | S3 URI of the uploaded object \(s3://bucket/key\) |
|
|
||||||
| `metadata` | object | Upload metadata including ETag and location |
|
| `metadata` | object | Upload metadata including ETag and location |
|
||||||
|
|
||||||
### `s3_get_object`
|
### `s3_get_object`
|
||||||
@@ -150,7 +149,6 @@ Copy an object within or between AWS S3 buckets
|
|||||||
| Parameter | Type | Description |
|
| Parameter | Type | Description |
|
||||||
| --------- | ---- | ----------- |
|
| --------- | ---- | ----------- |
|
||||||
| `url` | string | URL of the copied S3 object |
|
| `url` | string | URL of the copied S3 object |
|
||||||
| `uri` | string | S3 URI of the copied object \(s3://bucket/key\) |
|
|
||||||
| `metadata` | object | Copy operation metadata |
|
| `metadata` | object | Copy operation metadata |
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,120 +0,0 @@
|
|||||||
---
|
|
||||||
title: AWS Textract
|
|
||||||
description: Extract text, tables, and forms from documents
|
|
||||||
---
|
|
||||||
|
|
||||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
|
||||||
|
|
||||||
<BlockInfoCard
|
|
||||||
type="textract"
|
|
||||||
color="linear-gradient(135deg, #055F4E 0%, #56C0A7 100%)"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* MANUAL-CONTENT-START:intro */}
|
|
||||||
[AWS Textract](https://aws.amazon.com/textract/) is a powerful AI service from Amazon Web Services designed to automatically extract printed text, handwriting, tables, forms, key-value pairs, and other structured data from scanned documents and images. Textract leverages advanced optical character recognition (OCR) and document analysis to transform documents into actionable data, enabling automation, analytics, compliance, and more.
|
|
||||||
|
|
||||||
With AWS Textract, you can:
|
|
||||||
|
|
||||||
- **Extract text from images and documents**: Recognize printed text and handwriting in formats such as PDF, JPEG, PNG, or TIFF
|
|
||||||
- **Detect and extract tables**: Automatically find tables and output their structured content
|
|
||||||
- **Parse forms and key-value pairs**: Pull structured data from forms, including fields and their corresponding values
|
|
||||||
- **Identify signatures and layout features**: Detect signatures, geometric layout, and relationships between document elements
|
|
||||||
- **Customize extraction with queries**: Extract specific fields and answers using query-based extraction (e.g., "What is the invoice number?")
|
|
||||||
|
|
||||||
In Sim, the AWS Textract integration empowers your agents to intelligently process documents as part of their workflows. This unlocks automation scenarios such as data entry from invoices, onboarding documents, contracts, receipts, and more. Your agents can extract relevant data, analyze structured forms, and generate summaries or reports directly from document uploads or URLs. By connecting Sim with AWS Textract, you can reduce manual effort, improve data accuracy, and streamline your business processes with robust document understanding.
|
|
||||||
{/* MANUAL-CONTENT-END */}
|
|
||||||
|
|
||||||
|
|
||||||
## Usage Instructions
|
|
||||||
|
|
||||||
Integrate AWS Textract into your workflow to extract text, tables, forms, and key-value pairs from documents. Single-page mode supports JPEG, PNG, and single-page PDF. Multi-page mode supports multi-page PDF and TIFF.
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Tools
|
|
||||||
|
|
||||||
### `textract_parser`
|
|
||||||
|
|
||||||
Parse documents using AWS Textract OCR and document analysis
|
|
||||||
|
|
||||||
#### Input
|
|
||||||
|
|
||||||
| Parameter | Type | Required | Description |
|
|
||||||
| --------- | ---- | -------- | ----------- |
|
|
||||||
| `accessKeyId` | string | Yes | AWS Access Key ID |
|
|
||||||
| `secretAccessKey` | string | Yes | AWS Secret Access Key |
|
|
||||||
| `region` | string | Yes | AWS region for Textract service \(e.g., us-east-1\) |
|
|
||||||
| `processingMode` | string | No | Document type: single-page or multi-page. Defaults to single-page. |
|
|
||||||
| `filePath` | string | No | URL to a document to be processed \(JPEG, PNG, or single-page PDF\). |
|
|
||||||
| `s3Uri` | string | No | S3 URI for multi-page processing \(s3://bucket/key\). |
|
|
||||||
| `fileUpload` | object | No | File upload data from file-upload component |
|
|
||||||
| `featureTypes` | array | No | Feature types to detect: TABLES, FORMS, QUERIES, SIGNATURES, LAYOUT. If not specified, only text detection is performed. |
|
|
||||||
| `items` | string | No | Feature type |
|
|
||||||
| `queries` | array | No | Custom queries to extract specific information. Only used when featureTypes includes QUERIES. |
|
|
||||||
| `items` | object | No | Query configuration |
|
|
||||||
| `properties` | string | No | The query text |
|
|
||||||
| `Text` | string | No | No description |
|
|
||||||
| `Alias` | string | No | No description |
|
|
||||||
|
|
||||||
#### Output
|
|
||||||
|
|
||||||
| Parameter | Type | Description |
|
|
||||||
| --------- | ---- | ----------- |
|
|
||||||
| `blocks` | array | Array of Block objects containing detected text, tables, forms, and other elements |
|
|
||||||
| ↳ `BlockType` | string | Type of block \(PAGE, LINE, WORD, TABLE, CELL, KEY_VALUE_SET, etc.\) |
|
|
||||||
| ↳ `Id` | string | Unique identifier for the block |
|
|
||||||
| ↳ `Text` | string | Query text |
|
|
||||||
| ↳ `TextType` | string | Type of text \(PRINTED or HANDWRITING\) |
|
|
||||||
| ↳ `Confidence` | number | Confidence score \(0-100\) |
|
|
||||||
| ↳ `Page` | number | Page number |
|
|
||||||
| ↳ `Geometry` | object | Location and bounding box information |
|
|
||||||
| ↳ `BoundingBox` | object | Height as ratio of document height |
|
|
||||||
| ↳ `Height` | number | Height as ratio of document height |
|
|
||||||
| ↳ `Left` | number | Left position as ratio of document width |
|
|
||||||
| ↳ `Top` | number | Top position as ratio of document height |
|
|
||||||
| ↳ `Width` | number | Width as ratio of document width |
|
|
||||||
| ↳ `Height` | number | Height as ratio of document height |
|
|
||||||
| ↳ `Left` | number | Left position as ratio of document width |
|
|
||||||
| ↳ `Top` | number | Top position as ratio of document height |
|
|
||||||
| ↳ `Width` | number | Width as ratio of document width |
|
|
||||||
| ↳ `Polygon` | array | Polygon coordinates |
|
|
||||||
| ↳ `X` | number | X coordinate |
|
|
||||||
| ↳ `Y` | number | Y coordinate |
|
|
||||||
| ↳ `X` | number | X coordinate |
|
|
||||||
| ↳ `Y` | number | Y coordinate |
|
|
||||||
| ↳ `BoundingBox` | object | Height as ratio of document height |
|
|
||||||
| ↳ `Height` | number | Height as ratio of document height |
|
|
||||||
| ↳ `Left` | number | Left position as ratio of document width |
|
|
||||||
| ↳ `Top` | number | Top position as ratio of document height |
|
|
||||||
| ↳ `Width` | number | Width as ratio of document width |
|
|
||||||
| ↳ `Height` | number | Height as ratio of document height |
|
|
||||||
| ↳ `Left` | number | Left position as ratio of document width |
|
|
||||||
| ↳ `Top` | number | Top position as ratio of document height |
|
|
||||||
| ↳ `Width` | number | Width as ratio of document width |
|
|
||||||
| ↳ `Polygon` | array | Polygon coordinates |
|
|
||||||
| ↳ `X` | number | X coordinate |
|
|
||||||
| ↳ `Y` | number | Y coordinate |
|
|
||||||
| ↳ `X` | number | X coordinate |
|
|
||||||
| ↳ `Y` | number | Y coordinate |
|
|
||||||
| ↳ `Relationships` | array | Relationships to other blocks |
|
|
||||||
| ↳ `Type` | string | Relationship type \(CHILD, VALUE, ANSWER, etc.\) |
|
|
||||||
| ↳ `Ids` | array | IDs of related blocks |
|
|
||||||
| ↳ `Type` | string | Relationship type \(CHILD, VALUE, ANSWER, etc.\) |
|
|
||||||
| ↳ `Ids` | array | IDs of related blocks |
|
|
||||||
| ↳ `EntityTypes` | array | Entity types for KEY_VALUE_SET \(KEY or VALUE\) |
|
|
||||||
| ↳ `SelectionStatus` | string | For checkboxes: SELECTED or NOT_SELECTED |
|
|
||||||
| ↳ `RowIndex` | number | Row index for table cells |
|
|
||||||
| ↳ `ColumnIndex` | number | Column index for table cells |
|
|
||||||
| ↳ `RowSpan` | number | Row span for merged cells |
|
|
||||||
| ↳ `ColumnSpan` | number | Column span for merged cells |
|
|
||||||
| ↳ `Query` | object | Query information for QUERY blocks |
|
|
||||||
| ↳ `Text` | string | Query text |
|
|
||||||
| ↳ `Alias` | string | Query alias |
|
|
||||||
| ↳ `Pages` | array | Pages to search |
|
|
||||||
| ↳ `Alias` | string | Query alias |
|
|
||||||
| ↳ `Pages` | array | Pages to search |
|
|
||||||
| `documentMetadata` | object | Metadata about the analyzed document |
|
|
||||||
| ↳ `pages` | number | Number of pages in the document |
|
|
||||||
| `modelVersion` | string | Version of the Textract model used for processing |
|
|
||||||
|
|
||||||
|
|
||||||
@@ -6,7 +6,7 @@ description: Generate videos from text using AI
|
|||||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||||
|
|
||||||
<BlockInfoCard
|
<BlockInfoCard
|
||||||
type="video_generator_v2"
|
type="video_generator"
|
||||||
color="#181C1E"
|
color="#181C1E"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { Eye, EyeOff } from 'lucide-react'
|
import { ArrowRight, ChevronRight, Eye, EyeOff } from 'lucide-react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useRouter, useSearchParams } from 'next/navigation'
|
import { useRouter, useSearchParams } from 'next/navigation'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -21,10 +22,8 @@ import { getBaseUrl } from '@/lib/core/utils/urls'
|
|||||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||||
import { inter } from '@/app/_styles/fonts/inter/inter'
|
import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||||
import { BrandedButton } from '@/app/(auth)/components/branded-button'
|
|
||||||
import { SocialLoginButtons } from '@/app/(auth)/components/social-login-buttons'
|
import { SocialLoginButtons } from '@/app/(auth)/components/social-login-buttons'
|
||||||
import { SSOLoginButton } from '@/app/(auth)/components/sso-login-button'
|
import { SSOLoginButton } from '@/app/(auth)/components/sso-login-button'
|
||||||
import { useBrandedButtonClass } from '@/hooks/use-branded-button-class'
|
|
||||||
|
|
||||||
const logger = createLogger('LoginForm')
|
const logger = createLogger('LoginForm')
|
||||||
|
|
||||||
@@ -106,7 +105,8 @@ export default function LoginPage({
|
|||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [passwordErrors, setPasswordErrors] = useState<string[]>([])
|
const [passwordErrors, setPasswordErrors] = useState<string[]>([])
|
||||||
const [showValidationError, setShowValidationError] = useState(false)
|
const [showValidationError, setShowValidationError] = useState(false)
|
||||||
const buttonClass = useBrandedButtonClass()
|
const [buttonClass, setButtonClass] = useState('branded-button-gradient')
|
||||||
|
const [isButtonHovered, setIsButtonHovered] = useState(false)
|
||||||
|
|
||||||
const [callbackUrl, setCallbackUrl] = useState('/workspace')
|
const [callbackUrl, setCallbackUrl] = useState('/workspace')
|
||||||
const [isInviteFlow, setIsInviteFlow] = useState(false)
|
const [isInviteFlow, setIsInviteFlow] = useState(false)
|
||||||
@@ -114,6 +114,7 @@ export default function LoginPage({
|
|||||||
const [forgotPasswordOpen, setForgotPasswordOpen] = useState(false)
|
const [forgotPasswordOpen, setForgotPasswordOpen] = useState(false)
|
||||||
const [forgotPasswordEmail, setForgotPasswordEmail] = useState('')
|
const [forgotPasswordEmail, setForgotPasswordEmail] = useState('')
|
||||||
const [isSubmittingReset, setIsSubmittingReset] = useState(false)
|
const [isSubmittingReset, setIsSubmittingReset] = useState(false)
|
||||||
|
const [isResetButtonHovered, setIsResetButtonHovered] = useState(false)
|
||||||
const [resetStatus, setResetStatus] = useState<{
|
const [resetStatus, setResetStatus] = useState<{
|
||||||
type: 'success' | 'error' | null
|
type: 'success' | 'error' | null
|
||||||
message: string
|
message: string
|
||||||
@@ -122,7 +123,6 @@ export default function LoginPage({
|
|||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
const [emailErrors, setEmailErrors] = useState<string[]>([])
|
const [emailErrors, setEmailErrors] = useState<string[]>([])
|
||||||
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
|
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
|
||||||
const [resetSuccessMessage, setResetSuccessMessage] = useState<string | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true)
|
setMounted(true)
|
||||||
@@ -139,11 +139,31 @@ export default function LoginPage({
|
|||||||
|
|
||||||
const inviteFlow = searchParams.get('invite_flow') === 'true'
|
const inviteFlow = searchParams.get('invite_flow') === 'true'
|
||||||
setIsInviteFlow(inviteFlow)
|
setIsInviteFlow(inviteFlow)
|
||||||
|
|
||||||
const resetSuccess = searchParams.get('resetSuccess') === 'true'
|
|
||||||
if (resetSuccess) {
|
|
||||||
setResetSuccessMessage('Password reset successful. Please sign in with your new password.')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const checkCustomBrand = () => {
|
||||||
|
const computedStyle = getComputedStyle(document.documentElement)
|
||||||
|
const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim()
|
||||||
|
|
||||||
|
if (brandAccent && brandAccent !== '#6f3dfa') {
|
||||||
|
setButtonClass('branded-button-custom')
|
||||||
|
} else {
|
||||||
|
setButtonClass('branded-button-gradient')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkCustomBrand()
|
||||||
|
|
||||||
|
window.addEventListener('resize', checkCustomBrand)
|
||||||
|
const observer = new MutationObserver(checkCustomBrand)
|
||||||
|
observer.observe(document.documentElement, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['style', 'class'],
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', checkCustomBrand)
|
||||||
|
observer.disconnect()
|
||||||
}
|
}
|
||||||
}, [searchParams])
|
}, [searchParams])
|
||||||
|
|
||||||
@@ -182,13 +202,6 @@ export default function LoginPage({
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
|
|
||||||
const redirectToVerify = (emailToVerify: string) => {
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
sessionStorage.setItem('verificationEmail', emailToVerify)
|
|
||||||
}
|
|
||||||
router.push('/verify')
|
|
||||||
}
|
|
||||||
|
|
||||||
const formData = new FormData(e.currentTarget)
|
const formData = new FormData(e.currentTarget)
|
||||||
const emailRaw = formData.get('email') as string
|
const emailRaw = formData.get('email') as string
|
||||||
const email = emailRaw.trim().toLowerCase()
|
const email = emailRaw.trim().toLowerCase()
|
||||||
@@ -208,7 +221,6 @@ export default function LoginPage({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const safeCallbackUrl = validateCallbackUrl(callbackUrl) ? callbackUrl : '/workspace'
|
const safeCallbackUrl = validateCallbackUrl(callbackUrl) ? callbackUrl : '/workspace'
|
||||||
let errorHandled = false
|
|
||||||
|
|
||||||
const result = await client.signIn.email(
|
const result = await client.signIn.email(
|
||||||
{
|
{
|
||||||
@@ -219,16 +231,11 @@ export default function LoginPage({
|
|||||||
{
|
{
|
||||||
onError: (ctx) => {
|
onError: (ctx) => {
|
||||||
logger.error('Login error:', ctx.error)
|
logger.error('Login error:', ctx.error)
|
||||||
|
|
||||||
if (ctx.error.code?.includes('EMAIL_NOT_VERIFIED')) {
|
|
||||||
errorHandled = true
|
|
||||||
redirectToVerify(email)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
errorHandled = true
|
|
||||||
const errorMessage: string[] = ['Invalid email or password']
|
const errorMessage: string[] = ['Invalid email or password']
|
||||||
|
|
||||||
|
if (ctx.error.code?.includes('EMAIL_NOT_VERIFIED')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
ctx.error.code?.includes('BAD_REQUEST') ||
|
ctx.error.code?.includes('BAD_REQUEST') ||
|
||||||
ctx.error.message?.includes('Email and password sign in is not enabled')
|
ctx.error.message?.includes('Email and password sign in is not enabled')
|
||||||
@@ -264,7 +271,6 @@ export default function LoginPage({
|
|||||||
errorMessage.push('Too many requests. Please wait a moment before trying again.')
|
errorMessage.push('Too many requests. Please wait a moment before trying again.')
|
||||||
}
|
}
|
||||||
|
|
||||||
setResetSuccessMessage(null)
|
|
||||||
setPasswordErrors(errorMessage)
|
setPasswordErrors(errorMessage)
|
||||||
setShowValidationError(true)
|
setShowValidationError(true)
|
||||||
},
|
},
|
||||||
@@ -272,25 +278,15 @@ export default function LoginPage({
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (!result || result.error) {
|
if (!result || result.error) {
|
||||||
// Show error if not already handled by onError callback
|
|
||||||
if (!errorHandled) {
|
|
||||||
setResetSuccessMessage(null)
|
|
||||||
const errorMessage = result?.error?.message || 'Login failed. Please try again.'
|
|
||||||
setPasswordErrors([errorMessage])
|
|
||||||
setShowValidationError(true)
|
|
||||||
}
|
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear reset success message on successful login
|
|
||||||
setResetSuccessMessage(null)
|
|
||||||
|
|
||||||
// Explicit redirect fallback if better-auth doesn't redirect
|
|
||||||
router.push(safeCallbackUrl)
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err.message?.includes('not verified') || err.code?.includes('EMAIL_NOT_VERIFIED')) {
|
if (err.message?.includes('not verified') || err.code?.includes('EMAIL_NOT_VERIFIED')) {
|
||||||
redirectToVerify(email)
|
if (typeof window !== 'undefined') {
|
||||||
|
sessionStorage.setItem('verificationEmail', email)
|
||||||
|
}
|
||||||
|
router.push('/verify')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,13 +400,6 @@ export default function LoginPage({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Password reset success message */}
|
|
||||||
{resetSuccessMessage && (
|
|
||||||
<div className={`${inter.className} mt-1 space-y-1 text-[#4CAF50] text-xs`}>
|
|
||||||
<p>{resetSuccessMessage}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Email/Password Form - show unless explicitly disabled */}
|
{/* Email/Password Form - show unless explicitly disabled */}
|
||||||
{!isFalsy(getEnv('NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED')) && (
|
{!isFalsy(getEnv('NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED')) && (
|
||||||
<form onSubmit={onSubmit} className={`${inter.className} mt-8 space-y-8`}>
|
<form onSubmit={onSubmit} className={`${inter.className} mt-8 space-y-8`}>
|
||||||
@@ -493,14 +482,24 @@ export default function LoginPage({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<BrandedButton
|
<Button
|
||||||
type='submit'
|
type='submit'
|
||||||
|
onMouseEnter={() => setIsButtonHovered(true)}
|
||||||
|
onMouseLeave={() => setIsButtonHovered(false)}
|
||||||
|
className='group inline-flex w-full items-center justify-center gap-2 rounded-[10px] border border-[#6F3DFA] bg-gradient-to-b from-[#8357FF] to-[#6F3DFA] py-[6px] pr-[10px] pl-[12px] text-[15px] text-white shadow-[inset_0_2px_4px_0_#9B77FF] transition-all'
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
loading={isLoading}
|
|
||||||
loadingText='Signing in'
|
|
||||||
>
|
>
|
||||||
Sign in
|
<span className='flex items-center gap-1'>
|
||||||
</BrandedButton>
|
{isLoading ? 'Signing in...' : 'Sign in'}
|
||||||
|
<span className='inline-flex transition-transform duration-200 group-hover:translate-x-0.5'>
|
||||||
|
{isButtonHovered ? (
|
||||||
|
<ArrowRight className='h-4 w-4' aria-hidden='true' />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className='h-4 w-4' aria-hidden='true' />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -611,15 +610,25 @@ export default function LoginPage({
|
|||||||
<p>{resetStatus.message}</p>
|
<p>{resetStatus.message}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<BrandedButton
|
<Button
|
||||||
type='button'
|
type='button'
|
||||||
onClick={handleForgotPassword}
|
onClick={handleForgotPassword}
|
||||||
|
onMouseEnter={() => setIsResetButtonHovered(true)}
|
||||||
|
onMouseLeave={() => setIsResetButtonHovered(false)}
|
||||||
|
className='group inline-flex w-full items-center justify-center gap-2 rounded-[10px] border border-[#6F3DFA] bg-gradient-to-b from-[#8357FF] to-[#6F3DFA] py-[6px] pr-[10px] pl-[12px] text-[15px] text-white shadow-[inset_0_2px_4px_0_#9B77FF] transition-all'
|
||||||
disabled={isSubmittingReset}
|
disabled={isSubmittingReset}
|
||||||
loading={isSubmittingReset}
|
|
||||||
loadingText='Sending'
|
|
||||||
>
|
>
|
||||||
Send Reset Link
|
<span className='flex items-center gap-1'>
|
||||||
</BrandedButton>
|
{isSubmittingReset ? 'Sending...' : 'Send Reset Link'}
|
||||||
|
<span className='inline-flex transition-transform duration-200 group-hover:translate-x-0.5'>
|
||||||
|
{isResetButtonHovered ? (
|
||||||
|
<ArrowRight className='h-4 w-4' aria-hidden='true' />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className='h-4 w-4' aria-hidden='true' />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Eye, EyeOff } from 'lucide-react'
|
import { ArrowRight, ChevronRight, Eye, EyeOff } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { cn } from '@/lib/core/utils/cn'
|
import { cn } from '@/lib/core/utils/cn'
|
||||||
import { inter } from '@/app/_styles/fonts/inter/inter'
|
import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||||
import { BrandedButton } from '@/app/(auth)/components/branded-button'
|
|
||||||
|
|
||||||
interface RequestResetFormProps {
|
interface RequestResetFormProps {
|
||||||
email: string
|
email: string
|
||||||
@@ -27,6 +27,36 @@ export function RequestResetForm({
|
|||||||
statusMessage,
|
statusMessage,
|
||||||
className,
|
className,
|
||||||
}: RequestResetFormProps) {
|
}: RequestResetFormProps) {
|
||||||
|
const [buttonClass, setButtonClass] = useState('branded-button-gradient')
|
||||||
|
const [isButtonHovered, setIsButtonHovered] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const checkCustomBrand = () => {
|
||||||
|
const computedStyle = getComputedStyle(document.documentElement)
|
||||||
|
const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim()
|
||||||
|
|
||||||
|
if (brandAccent && brandAccent !== '#6f3dfa') {
|
||||||
|
setButtonClass('branded-button-custom')
|
||||||
|
} else {
|
||||||
|
setButtonClass('branded-button-gradient')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkCustomBrand()
|
||||||
|
|
||||||
|
window.addEventListener('resize', checkCustomBrand)
|
||||||
|
const observer = new MutationObserver(checkCustomBrand)
|
||||||
|
observer.observe(document.documentElement, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['style', 'class'],
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', checkCustomBrand)
|
||||||
|
observer.disconnect()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
onSubmit(email)
|
onSubmit(email)
|
||||||
@@ -64,14 +94,24 @@ export function RequestResetForm({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<BrandedButton
|
<Button
|
||||||
type='submit'
|
type='submit'
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
loading={isSubmitting}
|
onMouseEnter={() => setIsButtonHovered(true)}
|
||||||
loadingText='Sending'
|
onMouseLeave={() => setIsButtonHovered(false)}
|
||||||
|
className='group inline-flex w-full items-center justify-center gap-2 rounded-[10px] border border-[#6F3DFA] bg-gradient-to-b from-[#8357FF] to-[#6F3DFA] py-[6px] pr-[10px] pl-[12px] text-[15px] text-white shadow-[inset_0_2px_4px_0_#9B77FF] transition-all'
|
||||||
>
|
>
|
||||||
Send Reset Link
|
<span className='flex items-center gap-1'>
|
||||||
</BrandedButton>
|
{isSubmitting ? 'Sending...' : 'Send Reset Link'}
|
||||||
|
<span className='inline-flex transition-transform duration-200 group-hover:translate-x-0.5'>
|
||||||
|
{isButtonHovered ? (
|
||||||
|
<ArrowRight className='h-4 w-4' aria-hidden='true' />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className='h-4 w-4' aria-hidden='true' />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -98,6 +138,35 @@ export function SetNewPasswordForm({
|
|||||||
const [validationMessage, setValidationMessage] = useState('')
|
const [validationMessage, setValidationMessage] = useState('')
|
||||||
const [showPassword, setShowPassword] = useState(false)
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
|
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
|
||||||
|
const [buttonClass, setButtonClass] = useState('branded-button-gradient')
|
||||||
|
const [isButtonHovered, setIsButtonHovered] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const checkCustomBrand = () => {
|
||||||
|
const computedStyle = getComputedStyle(document.documentElement)
|
||||||
|
const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim()
|
||||||
|
|
||||||
|
if (brandAccent && brandAccent !== '#6f3dfa') {
|
||||||
|
setButtonClass('branded-button-custom')
|
||||||
|
} else {
|
||||||
|
setButtonClass('branded-button-gradient')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkCustomBrand()
|
||||||
|
|
||||||
|
window.addEventListener('resize', checkCustomBrand)
|
||||||
|
const observer = new MutationObserver(checkCustomBrand)
|
||||||
|
observer.observe(document.documentElement, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['style', 'class'],
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', checkCustomBrand)
|
||||||
|
observer.disconnect()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -227,14 +296,24 @@ export function SetNewPasswordForm({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<BrandedButton
|
<Button
|
||||||
type='submit'
|
|
||||||
disabled={isSubmitting || !token}
|
disabled={isSubmitting || !token}
|
||||||
loading={isSubmitting}
|
type='submit'
|
||||||
loadingText='Resetting'
|
onMouseEnter={() => setIsButtonHovered(true)}
|
||||||
|
onMouseLeave={() => setIsButtonHovered(false)}
|
||||||
|
className='group inline-flex w-full items-center justify-center gap-2 rounded-[10px] border border-[#6F3DFA] bg-gradient-to-b from-[#8357FF] to-[#6F3DFA] py-[6px] pr-[10px] pl-[12px] text-[15px] text-white shadow-[inset_0_2px_4px_0_#9B77FF] transition-all'
|
||||||
>
|
>
|
||||||
Reset Password
|
<span className='flex items-center gap-1'>
|
||||||
</BrandedButton>
|
{isSubmitting ? 'Resetting...' : 'Reset Password'}
|
||||||
|
<span className='inline-flex transition-transform duration-200 group-hover:translate-x-0.5'>
|
||||||
|
{isButtonHovered ? (
|
||||||
|
<ArrowRight className='h-4 w-4' aria-hidden='true' />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className='h-4 w-4' aria-hidden='true' />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
|
|
||||||
import { Suspense, useEffect, useState } from 'react'
|
import { Suspense, useEffect, useState } from 'react'
|
||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { Eye, EyeOff } from 'lucide-react'
|
import { ArrowRight, ChevronRight, Eye, EyeOff } from 'lucide-react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useRouter, useSearchParams } from 'next/navigation'
|
import { useRouter, useSearchParams } from 'next/navigation'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { client, useSession } from '@/lib/auth/auth-client'
|
import { client, useSession } from '@/lib/auth/auth-client'
|
||||||
@@ -13,10 +14,8 @@ import { cn } from '@/lib/core/utils/cn'
|
|||||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||||
import { inter } from '@/app/_styles/fonts/inter/inter'
|
import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||||
import { BrandedButton } from '@/app/(auth)/components/branded-button'
|
|
||||||
import { SocialLoginButtons } from '@/app/(auth)/components/social-login-buttons'
|
import { SocialLoginButtons } from '@/app/(auth)/components/social-login-buttons'
|
||||||
import { SSOLoginButton } from '@/app/(auth)/components/sso-login-button'
|
import { SSOLoginButton } from '@/app/(auth)/components/sso-login-button'
|
||||||
import { useBrandedButtonClass } from '@/hooks/use-branded-button-class'
|
|
||||||
|
|
||||||
const logger = createLogger('SignupForm')
|
const logger = createLogger('SignupForm')
|
||||||
|
|
||||||
@@ -96,7 +95,8 @@ function SignupFormContent({
|
|||||||
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
|
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
|
||||||
const [redirectUrl, setRedirectUrl] = useState('')
|
const [redirectUrl, setRedirectUrl] = useState('')
|
||||||
const [isInviteFlow, setIsInviteFlow] = useState(false)
|
const [isInviteFlow, setIsInviteFlow] = useState(false)
|
||||||
const buttonClass = useBrandedButtonClass()
|
const [buttonClass, setButtonClass] = useState('branded-button-gradient')
|
||||||
|
const [isButtonHovered, setIsButtonHovered] = useState(false)
|
||||||
|
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
const [nameErrors, setNameErrors] = useState<string[]>([])
|
const [nameErrors, setNameErrors] = useState<string[]>([])
|
||||||
@@ -126,6 +126,31 @@ function SignupFormContent({
|
|||||||
if (inviteFlowParam === 'true') {
|
if (inviteFlowParam === 'true') {
|
||||||
setIsInviteFlow(true)
|
setIsInviteFlow(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const checkCustomBrand = () => {
|
||||||
|
const computedStyle = getComputedStyle(document.documentElement)
|
||||||
|
const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim()
|
||||||
|
|
||||||
|
if (brandAccent && brandAccent !== '#6f3dfa') {
|
||||||
|
setButtonClass('branded-button-custom')
|
||||||
|
} else {
|
||||||
|
setButtonClass('branded-button-gradient')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkCustomBrand()
|
||||||
|
|
||||||
|
window.addEventListener('resize', checkCustomBrand)
|
||||||
|
const observer = new MutationObserver(checkCustomBrand)
|
||||||
|
observer.observe(document.documentElement, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['style', 'class'],
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', checkCustomBrand)
|
||||||
|
observer.disconnect()
|
||||||
|
}
|
||||||
}, [searchParams])
|
}, [searchParams])
|
||||||
|
|
||||||
const validatePassword = (passwordValue: string): string[] => {
|
const validatePassword = (passwordValue: string): string[] => {
|
||||||
@@ -475,14 +500,24 @@ function SignupFormContent({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<BrandedButton
|
<Button
|
||||||
type='submit'
|
type='submit'
|
||||||
|
onMouseEnter={() => setIsButtonHovered(true)}
|
||||||
|
onMouseLeave={() => setIsButtonHovered(false)}
|
||||||
|
className='group inline-flex w-full items-center justify-center gap-2 rounded-[10px] border border-[#6F3DFA] bg-gradient-to-b from-[#8357FF] to-[#6F3DFA] py-[6px] pr-[10px] pl-[12px] text-[15px] text-white shadow-[inset_0_2px_4px_0_#9B77FF] transition-all'
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
loading={isLoading}
|
|
||||||
loadingText='Creating account'
|
|
||||||
>
|
>
|
||||||
Create account
|
<span className='flex items-center gap-1'>
|
||||||
</BrandedButton>
|
{isLoading ? 'Creating account' : 'Create account'}
|
||||||
|
<span className='inline-flex transition-transform duration-200 group-hover:translate-x-0.5'>
|
||||||
|
{isButtonHovered ? (
|
||||||
|
<ArrowRight className='h-4 w-4' aria-hidden='true' />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className='h-4 w-4' aria-hidden='true' />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import { cn } from '@/lib/core/utils/cn'
|
|||||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||||
import { inter } from '@/app/_styles/fonts/inter/inter'
|
import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||||
import { useBrandedButtonClass } from '@/hooks/use-branded-button-class'
|
|
||||||
|
|
||||||
const logger = createLogger('SSOForm')
|
const logger = createLogger('SSOForm')
|
||||||
|
|
||||||
@@ -58,7 +57,7 @@ export default function SSOForm() {
|
|||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
const [emailErrors, setEmailErrors] = useState<string[]>([])
|
const [emailErrors, setEmailErrors] = useState<string[]>([])
|
||||||
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
|
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
|
||||||
const buttonClass = useBrandedButtonClass()
|
const [buttonClass, setButtonClass] = useState('branded-button-gradient')
|
||||||
const [callbackUrl, setCallbackUrl] = useState('/workspace')
|
const [callbackUrl, setCallbackUrl] = useState('/workspace')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -91,6 +90,31 @@ export default function SSOForm() {
|
|||||||
setShowEmailValidationError(true)
|
setShowEmailValidationError(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const checkCustomBrand = () => {
|
||||||
|
const computedStyle = getComputedStyle(document.documentElement)
|
||||||
|
const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim()
|
||||||
|
|
||||||
|
if (brandAccent && brandAccent !== '#6f3dfa') {
|
||||||
|
setButtonClass('branded-button-custom')
|
||||||
|
} else {
|
||||||
|
setButtonClass('branded-button-gradient')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkCustomBrand()
|
||||||
|
|
||||||
|
window.addEventListener('resize', checkCustomBrand)
|
||||||
|
const observer = new MutationObserver(checkCustomBrand)
|
||||||
|
observer.observe(document.documentElement, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['style', 'class'],
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', checkCustomBrand)
|
||||||
|
observer.disconnect()
|
||||||
|
}
|
||||||
}, [searchParams])
|
}, [searchParams])
|
||||||
|
|
||||||
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { cn } from '@/lib/core/utils/cn'
|
|||||||
import { inter } from '@/app/_styles/fonts/inter/inter'
|
import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||||
import { useVerification } from '@/app/(auth)/verify/use-verification'
|
import { useVerification } from '@/app/(auth)/verify/use-verification'
|
||||||
import { useBrandedButtonClass } from '@/hooks/use-branded-button-class'
|
|
||||||
|
|
||||||
interface VerifyContentProps {
|
interface VerifyContentProps {
|
||||||
hasEmailService: boolean
|
hasEmailService: boolean
|
||||||
@@ -59,7 +58,34 @@ function VerificationForm({
|
|||||||
setCountdown(30)
|
setCountdown(30)
|
||||||
}
|
}
|
||||||
|
|
||||||
const buttonClass = useBrandedButtonClass()
|
const [buttonClass, setButtonClass] = useState('branded-button-gradient')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const checkCustomBrand = () => {
|
||||||
|
const computedStyle = getComputedStyle(document.documentElement)
|
||||||
|
const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim()
|
||||||
|
|
||||||
|
if (brandAccent && brandAccent !== '#6f3dfa') {
|
||||||
|
setButtonClass('branded-button-custom')
|
||||||
|
} else {
|
||||||
|
setButtonClass('branded-button-gradient')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkCustomBrand()
|
||||||
|
|
||||||
|
window.addEventListener('resize', checkCustomBrand)
|
||||||
|
const observer = new MutationObserver(checkCustomBrand)
|
||||||
|
observer.observe(document.documentElement, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['style', 'class'],
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', checkCustomBrand)
|
||||||
|
observer.disconnect()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useRef, useState } from 'react'
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { X } from 'lucide-react'
|
import { X } from 'lucide-react'
|
||||||
import { Textarea } from '@/components/emcn'
|
import { Textarea } from '@/components/emcn'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import {
|
import {
|
||||||
@@ -17,7 +18,6 @@ import { isHosted } from '@/lib/core/config/feature-flags'
|
|||||||
import { cn } from '@/lib/core/utils/cn'
|
import { cn } from '@/lib/core/utils/cn'
|
||||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||||
import { BrandedButton } from '@/app/(auth)/components/branded-button'
|
|
||||||
import Footer from '@/app/(landing)/components/footer/footer'
|
import Footer from '@/app/(landing)/components/footer/footer'
|
||||||
import Nav from '@/app/(landing)/components/nav/nav'
|
import Nav from '@/app/(landing)/components/nav/nav'
|
||||||
|
|
||||||
@@ -493,17 +493,18 @@ export default function CareersPage() {
|
|||||||
|
|
||||||
{/* Submit Button */}
|
{/* Submit Button */}
|
||||||
<div className='flex justify-end pt-2'>
|
<div className='flex justify-end pt-2'>
|
||||||
<BrandedButton
|
<Button
|
||||||
type='submit'
|
type='submit'
|
||||||
disabled={isSubmitting || submitStatus === 'success'}
|
disabled={isSubmitting || submitStatus === 'success'}
|
||||||
loading={isSubmitting}
|
className='min-w-[200px] rounded-[10px] border border-[#6F3DFA] bg-gradient-to-b from-[#8357FF] to-[#6F3DFA] text-white shadow-[inset_0_2px_4px_0_#9B77FF] transition-all duration-300 hover:opacity-90 disabled:opacity-50'
|
||||||
loadingText='Submitting'
|
size='lg'
|
||||||
showArrow={false}
|
|
||||||
fullWidth={false}
|
|
||||||
className='min-w-[200px]'
|
|
||||||
>
|
>
|
||||||
{submitStatus === 'success' ? 'Submitted' : 'Submit Application'}
|
{isSubmitting
|
||||||
</BrandedButton>
|
? 'Submitting...'
|
||||||
|
: submitStatus === 'success'
|
||||||
|
? 'Submitted'
|
||||||
|
: 'Submit Application'}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export default function StatusIndicator() {
|
|||||||
href={statusUrl}
|
href={statusUrl}
|
||||||
target='_blank'
|
target='_blank'
|
||||||
rel='noopener noreferrer'
|
rel='noopener noreferrer'
|
||||||
className={`flex min-w-[165px] items-center gap-[6px] whitespace-nowrap text-[12px] transition-colors ${STATUS_COLORS[status]}`}
|
className={`flex items-center gap-[6px] whitespace-nowrap text-[12px] transition-colors ${STATUS_COLORS[status]}`}
|
||||||
aria-label={`System status: ${message}`}
|
aria-label={`System status: ${message}`}
|
||||||
>
|
>
|
||||||
<StatusDotIcon status={status} className='h-[6px] w-[6px]' aria-hidden='true' />
|
<StatusDotIcon status={status} className='h-[6px] w-[6px]' aria-hidden='true' />
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ export { LandingLoopNode } from './landing-canvas/landing-block/landing-loop-nod
|
|||||||
export { LandingNode } from './landing-canvas/landing-block/landing-node'
|
export { LandingNode } from './landing-canvas/landing-block/landing-node'
|
||||||
export type { LoopBlockProps } from './landing-canvas/landing-block/loop-block'
|
export type { LoopBlockProps } from './landing-canvas/landing-block/loop-block'
|
||||||
export { LoopBlock } from './landing-canvas/landing-block/loop-block'
|
export { LoopBlock } from './landing-canvas/landing-block/loop-block'
|
||||||
export type { SubBlockRowProps, TagProps } from './landing-canvas/landing-block/tag'
|
export type { TagProps } from './landing-canvas/landing-block/tag'
|
||||||
export { SubBlockRow, Tag } from './landing-canvas/landing-block/tag'
|
export { Tag } from './landing-canvas/landing-block/tag'
|
||||||
export type {
|
export type {
|
||||||
LandingBlockNode,
|
LandingBlockNode,
|
||||||
LandingCanvasProps,
|
LandingCanvasProps,
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import { BookIcon } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
SubBlockRow,
|
Tag,
|
||||||
type SubBlockRowProps,
|
type TagProps,
|
||||||
} from '@/app/(landing)/components/hero/components/landing-canvas/landing-block/tag'
|
} from '@/app/(landing)/components/hero/components/landing-canvas/landing-block/tag'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Data structure for a landing card component
|
* Data structure for a landing card component
|
||||||
* Matches the workflow block structure from the application
|
|
||||||
*/
|
*/
|
||||||
export interface LandingCardData {
|
export interface LandingCardData {
|
||||||
/** Icon element to display in the card header */
|
/** Icon element to display in the card header */
|
||||||
@@ -15,8 +15,8 @@ export interface LandingCardData {
|
|||||||
color: string | '#f6f6f6'
|
color: string | '#f6f6f6'
|
||||||
/** Name/title of the card */
|
/** Name/title of the card */
|
||||||
name: string
|
name: string
|
||||||
/** Optional subblock rows to display below the header */
|
/** Optional tags to display at the bottom of the card */
|
||||||
tags?: SubBlockRowProps[]
|
tags?: TagProps[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -28,8 +28,7 @@ export interface LandingBlockProps extends LandingCardData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Landing block component that displays a card with icon, name, and optional subblock rows
|
* Landing block component that displays a card with icon, name, and optional tags
|
||||||
* Styled to match the application's workflow blocks
|
|
||||||
* @param props - Component properties including icon, color, name, tags, and className
|
* @param props - Component properties including icon, color, name, tags, and className
|
||||||
* @returns A styled block card component
|
* @returns A styled block card component
|
||||||
*/
|
*/
|
||||||
@@ -40,37 +39,33 @@ export const LandingBlock = React.memo(function LandingBlock({
|
|||||||
tags,
|
tags,
|
||||||
className,
|
className,
|
||||||
}: LandingBlockProps) {
|
}: LandingBlockProps) {
|
||||||
const hasContentBelowHeader = tags && tags.length > 0
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`z-10 flex w-[250px] flex-col rounded-[8px] border border-[#E5E5E5] bg-white ${className ?? ''}`}
|
className={`z-10 flex w-64 flex-col items-start gap-3 rounded-[14px] border border-[#E5E5E5] bg-[#FEFEFE] p-3 ${className ?? ''}`}
|
||||||
|
style={{
|
||||||
|
boxShadow: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{/* Header - matches workflow-block.tsx header styling */}
|
<div className='flex w-full items-center justify-between'>
|
||||||
|
<div className='flex items-center gap-2.5'>
|
||||||
<div
|
<div
|
||||||
className={`flex items-center justify-between p-[8px] ${hasContentBelowHeader ? 'border-[#E5E5E5] border-b' : ''}`}
|
className='flex h-6 w-6 items-center justify-center rounded-[8px] text-white'
|
||||||
>
|
style={{ backgroundColor: color as string }}
|
||||||
<div className='flex min-w-0 flex-1 items-center gap-[10px]'>
|
|
||||||
<div
|
|
||||||
className='flex h-[24px] w-[24px] flex-shrink-0 items-center justify-center rounded-[6px]'
|
|
||||||
style={{ background: color as string }}
|
|
||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
</div>
|
</div>
|
||||||
<span className='truncate font-medium text-[#171717] text-[16px]' title={name}>
|
<p className='text-base text-card-foreground'>{name}</p>
|
||||||
{name}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<BookIcon className='h-4 w-4 text-muted-foreground' />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content - SubBlock Rows matching workflow-block.tsx */}
|
{tags && tags.length > 0 ? (
|
||||||
{hasContentBelowHeader && (
|
<div className='flex flex-wrap gap-2'>
|
||||||
<div className='flex flex-col gap-[8px] p-[8px]'>
|
|
||||||
{tags.map((tag) => (
|
{tags.map((tag) => (
|
||||||
<SubBlockRow key={tag.label} icon={tag.icon} label={tag.label} />
|
<Tag key={tag.label} icon={tag.icon} label={tag.label} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,14 +7,9 @@ import {
|
|||||||
type LandingCardData,
|
type LandingCardData,
|
||||||
} from '@/app/(landing)/components/hero/components/landing-canvas/landing-block/landing-block'
|
} from '@/app/(landing)/components/hero/components/landing-canvas/landing-block/landing-block'
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle Y offset from block top - matches HANDLE_POSITIONS.DEFAULT_Y_OFFSET
|
|
||||||
*/
|
|
||||||
const HANDLE_Y_OFFSET = 20
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* React Flow node component for the landing canvas
|
* React Flow node component for the landing canvas
|
||||||
* Styled to match the application's workflow blocks
|
* Includes CSS animations and connection handles
|
||||||
* @param props - Component properties containing node data
|
* @param props - Component properties containing node data
|
||||||
* @returns A React Flow compatible node component
|
* @returns A React Flow compatible node component
|
||||||
*/
|
*/
|
||||||
@@ -46,15 +41,15 @@ export const LandingNode = React.memo(function LandingNode({ data }: { data: Lan
|
|||||||
type='target'
|
type='target'
|
||||||
position={Position.Left}
|
position={Position.Left}
|
||||||
style={{
|
style={{
|
||||||
width: '7px',
|
width: '12px',
|
||||||
height: '20px',
|
height: '12px',
|
||||||
background: '#D1D1D1',
|
background: '#FEFEFE',
|
||||||
border: 'none',
|
border: '1px solid #E5E5E5',
|
||||||
borderRadius: '2px 0 0 2px',
|
borderRadius: '50%',
|
||||||
top: `${HANDLE_Y_OFFSET}px`,
|
top: '50%',
|
||||||
left: '-7px',
|
left: '-20px',
|
||||||
transform: 'translateY(-50%)',
|
transform: 'translateY(-50%)',
|
||||||
zIndex: 10,
|
zIndex: 2,
|
||||||
}}
|
}}
|
||||||
isConnectable={false}
|
isConnectable={false}
|
||||||
/>
|
/>
|
||||||
@@ -64,15 +59,15 @@ export const LandingNode = React.memo(function LandingNode({ data }: { data: Lan
|
|||||||
type='source'
|
type='source'
|
||||||
position={Position.Right}
|
position={Position.Right}
|
||||||
style={{
|
style={{
|
||||||
width: '7px',
|
width: '12px',
|
||||||
height: '20px',
|
height: '12px',
|
||||||
background: '#D1D1D1',
|
background: '#FEFEFE',
|
||||||
border: 'none',
|
border: '1px solid #E5E5E5',
|
||||||
borderRadius: '0 2px 2px 0',
|
borderRadius: '50%',
|
||||||
top: `${HANDLE_Y_OFFSET}px`,
|
top: '50%',
|
||||||
right: '-7px',
|
right: '-20px',
|
||||||
transform: 'translateY(-50%)',
|
transform: 'translateY(-50%)',
|
||||||
zIndex: 10,
|
zIndex: 2,
|
||||||
}}
|
}}
|
||||||
isConnectable={false}
|
isConnectable={false}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ export interface LoopBlockProps {
|
|||||||
/**
|
/**
|
||||||
* Loop block container component that provides a styled container
|
* Loop block container component that provides a styled container
|
||||||
* for grouping related elements with a dashed border
|
* for grouping related elements with a dashed border
|
||||||
* Styled to match the application's subflow containers
|
|
||||||
* @param props - Component properties including children and styling
|
* @param props - Component properties including children and styling
|
||||||
* @returns A styled loop container component
|
* @returns A styled loop container component
|
||||||
*/
|
*/
|
||||||
@@ -30,33 +29,33 @@ export const LoopBlock = React.memo(function LoopBlock({
|
|||||||
style={{
|
style={{
|
||||||
width: '1198px',
|
width: '1198px',
|
||||||
height: '528px',
|
height: '528px',
|
||||||
borderRadius: '8px',
|
borderRadius: '14px',
|
||||||
background: 'rgba(59, 130, 246, 0.08)',
|
background: 'rgba(59, 130, 246, 0.10)',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
...style,
|
...style,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Custom dashed border with SVG - 8px border radius to match blocks */}
|
{/* Custom dashed border with SVG */}
|
||||||
<svg
|
<svg
|
||||||
className='pointer-events-none absolute inset-0 h-full w-full'
|
className='pointer-events-none absolute inset-0 h-full w-full'
|
||||||
style={{ borderRadius: '8px' }}
|
style={{ borderRadius: '14px' }}
|
||||||
preserveAspectRatio='none'
|
preserveAspectRatio='none'
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
className='landing-loop-animated-dash'
|
className='landing-loop-animated-dash'
|
||||||
d='M 1190 527.5
|
d='M 1183.5 527.5
|
||||||
L 8 527.5
|
L 14 527.5
|
||||||
A 7.5 7.5 0 0 1 0.5 520
|
A 13.5 13.5 0 0 1 0.5 514
|
||||||
L 0.5 8
|
L 0.5 14
|
||||||
A 7.5 7.5 0 0 1 8 0.5
|
A 13.5 13.5 0 0 1 14 0.5
|
||||||
L 1190 0.5
|
L 1183.5 0.5
|
||||||
A 7.5 7.5 0 0 1 1197.5 8
|
A 13.5 13.5 0 0 1 1197 14
|
||||||
L 1197.5 520
|
L 1197 514
|
||||||
A 7.5 7.5 0 0 1 1190 527.5 Z'
|
A 13.5 13.5 0 0 1 1183.5 527.5 Z'
|
||||||
fill='none'
|
fill='none'
|
||||||
stroke='#3B82F6'
|
stroke='#3B82F6'
|
||||||
strokeWidth='1'
|
strokeWidth='1'
|
||||||
strokeDasharray='8 8'
|
strokeDasharray='12 12'
|
||||||
strokeLinecap='round'
|
strokeLinecap='round'
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
@@ -1,52 +1,25 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Properties for a subblock row component
|
* Properties for a tag component
|
||||||
* Matches the SubBlockRow pattern from workflow-block.tsx
|
|
||||||
*/
|
*/
|
||||||
export interface SubBlockRowProps {
|
export interface TagProps {
|
||||||
/** Icon element to display (optional, for visual context) */
|
/** Icon element to display in the tag */
|
||||||
icon?: React.ReactNode
|
icon: React.ReactNode
|
||||||
/** Text label for the row title */
|
/** Text label for the tag */
|
||||||
label: string
|
label: string
|
||||||
/** Optional value to display on the right side */
|
|
||||||
value?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Kept for backwards compatibility
|
* Tag component for displaying labeled icons in a compact format
|
||||||
|
* @param props - Tag properties including icon and label
|
||||||
|
* @returns A styled tag component
|
||||||
*/
|
*/
|
||||||
export type TagProps = SubBlockRowProps
|
export const Tag = React.memo(function Tag({ icon, label }: TagProps) {
|
||||||
|
|
||||||
/**
|
|
||||||
* SubBlockRow component matching the workflow block's subblock row style
|
|
||||||
* @param props - Row properties including label and optional value
|
|
||||||
* @returns A styled row component
|
|
||||||
*/
|
|
||||||
export const SubBlockRow = React.memo(function SubBlockRow({ label, value }: SubBlockRowProps) {
|
|
||||||
// Split label by colon to separate title and value if present
|
|
||||||
const [title, displayValue] = label.includes(':')
|
|
||||||
? label.split(':').map((s) => s.trim())
|
|
||||||
: [label, value]
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='flex items-center gap-[8px]'>
|
<div className='flex w-fit items-center gap-1 rounded-[8px] border border-gray-300 bg-white px-2 py-0.5'>
|
||||||
<span className='min-w-0 truncate text-[#888888] text-[14px] capitalize' title={title}>
|
<div className='h-3 w-3 text-muted-foreground'>{icon}</div>
|
||||||
{title}
|
<p className='text-muted-foreground text-xs leading-normal'>{label}</p>
|
||||||
</span>
|
|
||||||
{displayValue && (
|
|
||||||
<span
|
|
||||||
className='flex-1 truncate text-right text-[#171717] text-[14px]'
|
|
||||||
title={displayValue}
|
|
||||||
>
|
|
||||||
{displayValue}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
|
||||||
* Tag component - alias for SubBlockRow for backwards compatibility
|
|
||||||
*/
|
|
||||||
export const Tag = SubBlockRow
|
|
||||||
|
|||||||
@@ -9,10 +9,9 @@ import { LandingFlow } from '@/app/(landing)/components/hero/components/landing-
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Visual constants for landing node dimensions
|
* Visual constants for landing node dimensions
|
||||||
* Matches BLOCK_DIMENSIONS from the application
|
|
||||||
*/
|
*/
|
||||||
export const CARD_WIDTH = 250
|
export const CARD_WIDTH = 256
|
||||||
export const CARD_HEIGHT = 100
|
export const CARD_HEIGHT = 92
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Landing block node with positioning information
|
* Landing block node with positioning information
|
||||||
|
|||||||
@@ -4,29 +4,33 @@ import React from 'react'
|
|||||||
import { type EdgeProps, getSmoothStepPath, Position } from 'reactflow'
|
import { type EdgeProps, getSmoothStepPath, Position } from 'reactflow'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom edge component with animated dashed line
|
* Custom edge component with animated dotted line that floats between handles
|
||||||
* Styled to match the application's workflow edges with rectangular handles
|
|
||||||
* @param props - React Flow edge properties
|
* @param props - React Flow edge properties
|
||||||
* @returns An animated dashed edge component
|
* @returns An animated dotted edge component
|
||||||
*/
|
*/
|
||||||
export const LandingEdge = React.memo(function LandingEdge(props: EdgeProps) {
|
export const LandingEdge = React.memo(function LandingEdge(props: EdgeProps) {
|
||||||
const { id, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, style } = props
|
const { id, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, style, data } =
|
||||||
|
props
|
||||||
|
|
||||||
// Adjust the connection points to connect flush with rectangular handles
|
// Adjust the connection points to create floating effect
|
||||||
// Handle width is 7px, positioned at -7px from edge
|
// Account for handle size (12px) and additional spacing
|
||||||
|
const handleRadius = 6 // Half of handle width (12px)
|
||||||
|
const floatingGap = 1 // Additional gap for floating effect
|
||||||
|
|
||||||
|
// Calculate adjusted positions based on edge direction
|
||||||
let adjustedSourceX = sourceX
|
let adjustedSourceX = sourceX
|
||||||
let adjustedTargetX = targetX
|
let adjustedTargetX = targetX
|
||||||
|
|
||||||
if (sourcePosition === Position.Right) {
|
if (sourcePosition === Position.Right) {
|
||||||
adjustedSourceX = sourceX + 1
|
adjustedSourceX = sourceX + handleRadius + floatingGap
|
||||||
} else if (sourcePosition === Position.Left) {
|
} else if (sourcePosition === Position.Left) {
|
||||||
adjustedSourceX = sourceX - 1
|
adjustedSourceX = sourceX - handleRadius - floatingGap
|
||||||
}
|
}
|
||||||
|
|
||||||
if (targetPosition === Position.Left) {
|
if (targetPosition === Position.Left) {
|
||||||
adjustedTargetX = targetX - 1
|
adjustedTargetX = targetX - handleRadius - floatingGap
|
||||||
} else if (targetPosition === Position.Right) {
|
} else if (targetPosition === Position.Right) {
|
||||||
adjustedTargetX = targetX + 1
|
adjustedTargetX = targetX + handleRadius + floatingGap
|
||||||
}
|
}
|
||||||
|
|
||||||
const [path] = getSmoothStepPath({
|
const [path] = getSmoothStepPath({
|
||||||
@@ -36,8 +40,8 @@ export const LandingEdge = React.memo(function LandingEdge(props: EdgeProps) {
|
|||||||
targetY,
|
targetY,
|
||||||
sourcePosition,
|
sourcePosition,
|
||||||
targetPosition,
|
targetPosition,
|
||||||
borderRadius: 8,
|
borderRadius: 20,
|
||||||
offset: 16,
|
offset: 10,
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { ArrowUp, CodeIcon } from 'lucide-react'
|
import {
|
||||||
|
ArrowUp,
|
||||||
|
BinaryIcon,
|
||||||
|
BookIcon,
|
||||||
|
CalendarIcon,
|
||||||
|
CodeIcon,
|
||||||
|
Globe2Icon,
|
||||||
|
MessageSquareIcon,
|
||||||
|
VariableIcon,
|
||||||
|
} from 'lucide-react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { type Edge, type Node, Position } from 'reactflow'
|
import { type Edge, type Node, Position } from 'reactflow'
|
||||||
import {
|
import {
|
||||||
@@ -14,6 +23,7 @@ import {
|
|||||||
JiraIcon,
|
JiraIcon,
|
||||||
LinearIcon,
|
LinearIcon,
|
||||||
NotionIcon,
|
NotionIcon,
|
||||||
|
OpenAIIcon,
|
||||||
OutlookIcon,
|
OutlookIcon,
|
||||||
PackageSearchIcon,
|
PackageSearchIcon,
|
||||||
PineconeIcon,
|
PineconeIcon,
|
||||||
@@ -55,56 +65,67 @@ const SERVICE_TEMPLATES = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Landing blocks for the canvas preview
|
* Landing blocks for the canvas preview
|
||||||
* Styled to match the application's workflow blocks with subblock rows
|
|
||||||
*/
|
*/
|
||||||
const LANDING_BLOCKS: LandingManualBlock[] = [
|
const LANDING_BLOCKS: LandingManualBlock[] = [
|
||||||
{
|
{
|
||||||
id: 'schedule',
|
id: 'schedule',
|
||||||
name: 'Schedule',
|
name: 'Schedule',
|
||||||
color: '#7B68EE',
|
color: '#7B68EE',
|
||||||
icon: <ScheduleIcon className='h-[16px] w-[16px] text-white' />,
|
icon: <ScheduleIcon className='h-4 w-4' />,
|
||||||
positions: {
|
positions: {
|
||||||
mobile: { x: 8, y: 60 },
|
mobile: { x: 8, y: 60 },
|
||||||
tablet: { x: 40, y: 120 },
|
tablet: { x: 40, y: 120 },
|
||||||
desktop: { x: 60, y: 180 },
|
desktop: { x: 60, y: 180 },
|
||||||
},
|
},
|
||||||
tags: [{ label: 'Time: 09:00AM Daily' }, { label: 'Timezone: PST' }],
|
tags: [
|
||||||
|
{ icon: <CalendarIcon className='h-3 w-3' />, label: '09:00AM Daily' },
|
||||||
|
{ icon: <Globe2Icon className='h-3 w-3' />, label: 'PST' },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'knowledge',
|
id: 'knowledge',
|
||||||
name: 'Knowledge',
|
name: 'Knowledge',
|
||||||
color: '#00B0B0',
|
color: '#00B0B0',
|
||||||
icon: <PackageSearchIcon className='h-[16px] w-[16px] text-white' />,
|
icon: <PackageSearchIcon className='h-4 w-4' />,
|
||||||
positions: {
|
positions: {
|
||||||
mobile: { x: 120, y: 140 },
|
mobile: { x: 120, y: 140 },
|
||||||
tablet: { x: 220, y: 200 },
|
tablet: { x: 220, y: 200 },
|
||||||
desktop: { x: 420, y: 241 },
|
desktop: { x: 420, y: 241 },
|
||||||
},
|
},
|
||||||
tags: [{ label: 'Source: Product Vector DB' }, { label: 'Limit: 10' }],
|
tags: [
|
||||||
|
{ icon: <BookIcon className='h-3 w-3' />, label: 'Product Vector DB' },
|
||||||
|
{ icon: <BinaryIcon className='h-3 w-3' />, label: 'Limit: 10' },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'agent',
|
id: 'agent',
|
||||||
name: 'Agent',
|
name: 'Agent',
|
||||||
color: '#802FFF',
|
color: '#802FFF',
|
||||||
icon: <AgentIcon className='h-[16px] w-[16px] text-white' />,
|
icon: <AgentIcon className='h-4 w-4' />,
|
||||||
positions: {
|
positions: {
|
||||||
mobile: { x: 340, y: 60 },
|
mobile: { x: 340, y: 60 },
|
||||||
tablet: { x: 540, y: 120 },
|
tablet: { x: 540, y: 120 },
|
||||||
desktop: { x: 880, y: 142 },
|
desktop: { x: 880, y: 142 },
|
||||||
},
|
},
|
||||||
tags: [{ label: 'Model: gpt-5' }, { label: 'Prompt: You are a support ag...' }],
|
tags: [
|
||||||
|
{ icon: <OpenAIIcon className='h-3 w-3' />, label: 'gpt-5' },
|
||||||
|
{ icon: <MessageSquareIcon className='h-3 w-3' />, label: 'You are a support ag...' },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'function',
|
id: 'function',
|
||||||
name: 'Function',
|
name: 'Function',
|
||||||
color: '#FF402F',
|
color: '#FF402F',
|
||||||
icon: <CodeIcon className='h-[16px] w-[16px] text-white' />,
|
icon: <CodeIcon className='h-4 w-4' />,
|
||||||
positions: {
|
positions: {
|
||||||
mobile: { x: 480, y: 220 },
|
mobile: { x: 480, y: 220 },
|
||||||
tablet: { x: 740, y: 280 },
|
tablet: { x: 740, y: 280 },
|
||||||
desktop: { x: 880, y: 340 },
|
desktop: { x: 880, y: 340 },
|
||||||
},
|
},
|
||||||
tags: [{ label: 'Language: Python' }, { label: 'Code: time = "2025-09-01...' }],
|
tags: [
|
||||||
|
{ icon: <CodeIcon className='h-3 w-3' />, label: 'Python' },
|
||||||
|
{ icon: <VariableIcon className='h-3 w-3' />, label: 'time = "2025-09-01...' },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ function PricingCard({
|
|||||||
*/
|
*/
|
||||||
export default function LandingPricing() {
|
export default function LandingPricing() {
|
||||||
return (
|
return (
|
||||||
<section id='pricing' className='px-4 pt-[23px] sm:px-0 sm:pt-[4px]' aria-label='Pricing plans'>
|
<section id='pricing' className='px-4 pt-[19px] sm:px-0 sm:pt-0' aria-label='Pricing plans'>
|
||||||
<h2 className='sr-only'>Pricing Plans</h2>
|
<h2 className='sr-only'>Pricing Plans</h2>
|
||||||
<div className='relative mx-auto w-full max-w-[1289px]'>
|
<div className='relative mx-auto w-full max-w-[1289px]'>
|
||||||
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2 sm:gap-0 lg:grid-cols-4'>
|
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2 sm:gap-0 lg:grid-cols-4'>
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import { useBrandConfig } from '@/lib/branding/branding'
|
|||||||
import { isHosted } from '@/lib/core/config/feature-flags'
|
import { isHosted } from '@/lib/core/config/feature-flags'
|
||||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||||
import { getFormattedGitHubStars } from '@/app/(landing)/actions/github'
|
import { getFormattedGitHubStars } from '@/app/(landing)/actions/github'
|
||||||
import { useBrandedButtonClass } from '@/hooks/use-branded-button-class'
|
|
||||||
|
|
||||||
const logger = createLogger('nav')
|
const logger = createLogger('nav')
|
||||||
|
|
||||||
@@ -21,12 +20,11 @@ interface NavProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Nav({ hideAuthButtons = false, variant = 'landing' }: NavProps = {}) {
|
export default function Nav({ hideAuthButtons = false, variant = 'landing' }: NavProps = {}) {
|
||||||
const [githubStars, setGithubStars] = useState('26.1k')
|
const [githubStars, setGithubStars] = useState('25.1k')
|
||||||
const [isHovered, setIsHovered] = useState(false)
|
const [isHovered, setIsHovered] = useState(false)
|
||||||
const [isLoginHovered, setIsLoginHovered] = useState(false)
|
const [isLoginHovered, setIsLoginHovered] = useState(false)
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const brand = useBrandConfig()
|
const brand = useBrandConfig()
|
||||||
const buttonClass = useBrandedButtonClass()
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (variant !== 'landing') return
|
if (variant !== 'landing') return
|
||||||
@@ -185,7 +183,7 @@ export default function Nav({ hideAuthButtons = false, variant = 'landing' }: Na
|
|||||||
href='/signup'
|
href='/signup'
|
||||||
onMouseEnter={() => setIsHovered(true)}
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
onMouseLeave={() => setIsHovered(false)}
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
className={`${buttonClass} group inline-flex items-center justify-center gap-2 rounded-[10px] py-[6px] pr-[10px] pl-[12px] text-[15px] text-white transition-all`}
|
className='group inline-flex items-center justify-center gap-2 rounded-[10px] border border-[#6F3DFA] bg-gradient-to-b from-[#8357FF] to-[#6F3DFA] py-[6px] pr-[10px] pl-[12px] text-[14px] text-white shadow-[inset_0_2px_4px_0_#9B77FF] transition-all sm:text-[16px]'
|
||||||
aria-label='Get started with Sim - Sign up for free'
|
aria-label='Get started with Sim - Sign up for free'
|
||||||
prefetch={true}
|
prefetch={true}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { ArrowLeft, ChevronLeft } from 'lucide-react'
|
|
||||||
import Link from 'next/link'
|
|
||||||
|
|
||||||
export function BackLink() {
|
|
||||||
const [isHovered, setIsHovered] = useState(false)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
href='/studio'
|
|
||||||
className='group flex items-center gap-1 text-gray-600 text-sm hover:text-gray-900'
|
|
||||||
onMouseEnter={() => setIsHovered(true)}
|
|
||||||
onMouseLeave={() => setIsHovered(false)}
|
|
||||||
>
|
|
||||||
<span className='group-hover:-translate-x-0.5 inline-flex transition-transform duration-200'>
|
|
||||||
{isHovered ? (
|
|
||||||
<ArrowLeft className='h-4 w-4' aria-hidden='true' />
|
|
||||||
) : (
|
|
||||||
<ChevronLeft className='h-4 w-4' aria-hidden='true' />
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
Back to Sim Studio
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -5,10 +5,7 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/emcn'
|
|||||||
import { FAQ } from '@/lib/blog/faq'
|
import { FAQ } from '@/lib/blog/faq'
|
||||||
import { getAllPostMeta, getPostBySlug, getRelatedPosts } from '@/lib/blog/registry'
|
import { getAllPostMeta, getPostBySlug, getRelatedPosts } from '@/lib/blog/registry'
|
||||||
import { buildArticleJsonLd, buildBreadcrumbJsonLd, buildPostMetadata } from '@/lib/blog/seo'
|
import { buildArticleJsonLd, buildBreadcrumbJsonLd, buildPostMetadata } from '@/lib/blog/seo'
|
||||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
|
||||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||||
import { BackLink } from '@/app/(landing)/studio/[slug]/back-link'
|
|
||||||
import { ShareButton } from '@/app/(landing)/studio/[slug]/share-button'
|
|
||||||
|
|
||||||
export async function generateStaticParams() {
|
export async function generateStaticParams() {
|
||||||
const posts = await getAllPostMeta()
|
const posts = await getAllPostMeta()
|
||||||
@@ -51,7 +48,9 @@ export default async function Page({ params }: { params: Promise<{ slug: string
|
|||||||
/>
|
/>
|
||||||
<header className='mx-auto max-w-[1450px] px-6 pt-8 sm:px-8 sm:pt-12 md:px-12 md:pt-16'>
|
<header className='mx-auto max-w-[1450px] px-6 pt-8 sm:px-8 sm:pt-12 md:px-12 md:pt-16'>
|
||||||
<div className='mb-6'>
|
<div className='mb-6'>
|
||||||
<BackLink />
|
<Link href='/studio' className='text-gray-600 text-sm hover:text-gray-900'>
|
||||||
|
← Back to Sim Studio
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className='flex flex-col gap-8 md:flex-row md:gap-12'>
|
<div className='flex flex-col gap-8 md:flex-row md:gap-12'>
|
||||||
<div className='w-full flex-shrink-0 md:w-[450px]'>
|
<div className='w-full flex-shrink-0 md:w-[450px]'>
|
||||||
@@ -76,8 +75,7 @@ export default async function Page({ params }: { params: Promise<{ slug: string
|
|||||||
>
|
>
|
||||||
{post.title}
|
{post.title}
|
||||||
</h1>
|
</h1>
|
||||||
<div className='mt-4 flex items-center justify-between'>
|
<div className='mt-4 flex items-center gap-3'>
|
||||||
<div className='flex items-center gap-3'>
|
|
||||||
{(post.authors || [post.author]).map((a, idx) => (
|
{(post.authors || [post.author]).map((a, idx) => (
|
||||||
<div key={idx} className='flex items-center gap-2'>
|
<div key={idx} className='flex items-center gap-2'>
|
||||||
{a?.avatarUrl ? (
|
{a?.avatarUrl ? (
|
||||||
@@ -100,8 +98,6 @@ export default async function Page({ params }: { params: Promise<{ slug: string
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<ShareButton url={`${getBaseUrl()}/studio/${slug}`} title={post.title} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<hr className='mt-8 border-gray-200 border-t sm:mt-12' />
|
<hr className='mt-8 border-gray-200 border-t sm:mt-12' />
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { Share2 } from 'lucide-react'
|
|
||||||
import { Popover, PopoverContent, PopoverItem, PopoverTrigger } from '@/components/emcn'
|
|
||||||
|
|
||||||
interface ShareButtonProps {
|
|
||||||
url: string
|
|
||||||
title: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ShareButton({ url, title }: ShareButtonProps) {
|
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
const [copied, setCopied] = useState(false)
|
|
||||||
|
|
||||||
const handleCopyLink = async () => {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(url)
|
|
||||||
setCopied(true)
|
|
||||||
setTimeout(() => {
|
|
||||||
setCopied(false)
|
|
||||||
setOpen(false)
|
|
||||||
}, 1000)
|
|
||||||
} catch {
|
|
||||||
setOpen(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleShareTwitter = () => {
|
|
||||||
const tweetUrl = `https://twitter.com/intent/tweet?url=${encodeURIComponent(url)}&text=${encodeURIComponent(title)}`
|
|
||||||
window.open(tweetUrl, '_blank', 'noopener,noreferrer')
|
|
||||||
setOpen(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleShareLinkedIn = () => {
|
|
||||||
const linkedInUrl = `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(url)}`
|
|
||||||
window.open(linkedInUrl, '_blank', 'noopener,noreferrer')
|
|
||||||
setOpen(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popover
|
|
||||||
open={open}
|
|
||||||
onOpenChange={setOpen}
|
|
||||||
variant='secondary'
|
|
||||||
size='sm'
|
|
||||||
colorScheme='inverted'
|
|
||||||
>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<button
|
|
||||||
className='flex items-center gap-1.5 text-gray-600 text-sm hover:text-gray-900'
|
|
||||||
aria-label='Share this post'
|
|
||||||
>
|
|
||||||
<Share2 className='h-4 w-4' />
|
|
||||||
<span>Share</span>
|
|
||||||
</button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent align='end' minWidth={140}>
|
|
||||||
<PopoverItem onClick={handleCopyLink}>{copied ? 'Copied!' : 'Copy link'}</PopoverItem>
|
|
||||||
<PopoverItem onClick={handleShareTwitter}>Share on X</PopoverItem>
|
|
||||||
<PopoverItem onClick={handleShareLinkedIn}>Share on LinkedIn</PopoverItem>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -22,7 +22,7 @@ export default async function StudioIndex({
|
|||||||
? filtered.sort((a, b) => {
|
? filtered.sort((a, b) => {
|
||||||
if (a.featured && !b.featured) return -1
|
if (a.featured && !b.featured) return -1
|
||||||
if (!a.featured && b.featured) return 1
|
if (!a.featured && b.featured) return 1
|
||||||
return new Date(b.date).getTime() - new Date(a.date).getTime()
|
return 0
|
||||||
})
|
})
|
||||||
: filtered
|
: filtered
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import type { AgentCapabilities, AgentSkill } from '@/lib/a2a/types'
|
|||||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||||
import { getRedisClient } from '@/lib/core/config/redis'
|
import { getRedisClient } from '@/lib/core/config/redis'
|
||||||
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
|
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
|
||||||
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
|
|
||||||
|
|
||||||
const logger = createLogger('A2AAgentCardAPI')
|
const logger = createLogger('A2AAgentCardAPI')
|
||||||
|
|
||||||
@@ -96,11 +95,6 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<Ro
|
|||||||
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
|
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const workspaceAccess = await checkWorkspaceAccess(existingAgent.workspaceId, auth.userId)
|
|
||||||
if (!workspaceAccess.canWrite) {
|
|
||||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -166,11 +160,6 @@ export async function DELETE(request: NextRequest, { params }: { params: Promise
|
|||||||
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
|
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const workspaceAccess = await checkWorkspaceAccess(existingAgent.workspaceId, auth.userId)
|
|
||||||
if (!workspaceAccess.canWrite) {
|
|
||||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.delete(a2aAgent).where(eq(a2aAgent.id, agentId))
|
await db.delete(a2aAgent).where(eq(a2aAgent.id, agentId))
|
||||||
|
|
||||||
logger.info(`Deleted A2A agent: ${agentId}`)
|
logger.info(`Deleted A2A agent: ${agentId}`)
|
||||||
@@ -205,11 +194,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<R
|
|||||||
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
|
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const workspaceAccess = await checkWorkspaceAccess(existingAgent.workspaceId, auth.userId)
|
|
||||||
if (!workspaceAccess.canWrite) {
|
|
||||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const action = body.action as 'publish' | 'unpublish' | 'refresh'
|
const action = body.action as 'publish' | 'unpublish' | 'refresh'
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||||
import { getBrandConfig } from '@/lib/branding/branding'
|
import { getBrandConfig } from '@/lib/branding/branding'
|
||||||
import { acquireLock, getRedisClient, releaseLock } from '@/lib/core/config/redis'
|
import { acquireLock, getRedisClient, releaseLock } from '@/lib/core/config/redis'
|
||||||
import { validateExternalUrl } from '@/lib/core/security/input-validation'
|
|
||||||
import { SSE_HEADERS } from '@/lib/core/utils/sse'
|
import { SSE_HEADERS } from '@/lib/core/utils/sse'
|
||||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||||
import { markExecutionCancelled } from '@/lib/execution/cancellation'
|
import { markExecutionCancelled } from '@/lib/execution/cancellation'
|
||||||
@@ -1119,13 +1118,17 @@ async function handlePushNotificationSet(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const urlValidation = validateExternalUrl(
|
try {
|
||||||
params.pushNotificationConfig.url,
|
const url = new URL(params.pushNotificationConfig.url)
|
||||||
'Push notification URL'
|
if (url.protocol !== 'https:') {
|
||||||
)
|
|
||||||
if (!urlValidation.isValid) {
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
createError(id, A2A_ERROR_CODES.INVALID_PARAMS, urlValidation.error || 'Invalid URL'),
|
createError(id, A2A_ERROR_CODES.INVALID_PARAMS, 'Push notification URL must use HTTPS'),
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
createError(id, A2A_ERROR_CODES.INVALID_PARAMS, 'Invalid push notification URL'),
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,6 @@ import { createLogger } from '@sim/logger'
|
|||||||
import { and, desc, eq, inArray } from 'drizzle-orm'
|
import { and, desc, eq, inArray } from 'drizzle-orm'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { refreshOAuthToken } from '@/lib/oauth'
|
import { refreshOAuthToken } from '@/lib/oauth'
|
||||||
import {
|
|
||||||
getMicrosoftRefreshTokenExpiry,
|
|
||||||
isMicrosoftProvider,
|
|
||||||
PROACTIVE_REFRESH_THRESHOLD_DAYS,
|
|
||||||
} from '@/lib/oauth/microsoft'
|
|
||||||
|
|
||||||
const logger = createLogger('OAuthUtilsAPI')
|
const logger = createLogger('OAuthUtilsAPI')
|
||||||
|
|
||||||
@@ -210,32 +205,15 @@ export async function refreshAccessTokenIfNeeded(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Decide if we should refresh: token missing OR expired
|
// Decide if we should refresh: token missing OR expired
|
||||||
const accessTokenExpiresAt = credential.accessTokenExpiresAt
|
const expiresAt = credential.accessTokenExpiresAt
|
||||||
const refreshTokenExpiresAt = credential.refreshTokenExpiresAt
|
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
|
const shouldRefresh =
|
||||||
// Check if access token needs refresh (missing or expired)
|
!!credential.refreshToken && (!credential.accessToken || (expiresAt && expiresAt <= now))
|
||||||
const accessTokenNeedsRefresh =
|
|
||||||
!!credential.refreshToken &&
|
|
||||||
(!credential.accessToken || (accessTokenExpiresAt && accessTokenExpiresAt <= now))
|
|
||||||
|
|
||||||
// Check if we should proactively refresh to prevent refresh token expiry
|
|
||||||
// This applies to Microsoft providers whose refresh tokens expire after 90 days of inactivity
|
|
||||||
const proactiveRefreshThreshold = new Date(
|
|
||||||
now.getTime() + PROACTIVE_REFRESH_THRESHOLD_DAYS * 24 * 60 * 60 * 1000
|
|
||||||
)
|
|
||||||
const refreshTokenNeedsProactiveRefresh =
|
|
||||||
!!credential.refreshToken &&
|
|
||||||
isMicrosoftProvider(credential.providerId) &&
|
|
||||||
refreshTokenExpiresAt &&
|
|
||||||
refreshTokenExpiresAt <= proactiveRefreshThreshold
|
|
||||||
|
|
||||||
const shouldRefresh = accessTokenNeedsRefresh || refreshTokenNeedsProactiveRefresh
|
|
||||||
|
|
||||||
const accessToken = credential.accessToken
|
const accessToken = credential.accessToken
|
||||||
|
|
||||||
if (shouldRefresh) {
|
if (shouldRefresh) {
|
||||||
logger.info(`[${requestId}] Refreshing token for credential`)
|
logger.info(`[${requestId}] Token expired, attempting to refresh for credential`)
|
||||||
try {
|
try {
|
||||||
const refreshedToken = await refreshOAuthToken(
|
const refreshedToken = await refreshOAuthToken(
|
||||||
credential.providerId,
|
credential.providerId,
|
||||||
@@ -249,15 +227,11 @@ export async function refreshAccessTokenIfNeeded(
|
|||||||
userId: credential.userId,
|
userId: credential.userId,
|
||||||
hasRefreshToken: !!credential.refreshToken,
|
hasRefreshToken: !!credential.refreshToken,
|
||||||
})
|
})
|
||||||
if (!accessTokenNeedsRefresh && accessToken) {
|
|
||||||
logger.info(`[${requestId}] Proactive refresh failed but access token still valid`)
|
|
||||||
return accessToken
|
|
||||||
}
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prepare update data
|
// Prepare update data
|
||||||
const updateData: Record<string, unknown> = {
|
const updateData: any = {
|
||||||
accessToken: refreshedToken.accessToken,
|
accessToken: refreshedToken.accessToken,
|
||||||
accessTokenExpiresAt: new Date(Date.now() + refreshedToken.expiresIn * 1000),
|
accessTokenExpiresAt: new Date(Date.now() + refreshedToken.expiresIn * 1000),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
@@ -269,10 +243,6 @@ export async function refreshAccessTokenIfNeeded(
|
|||||||
updateData.refreshToken = refreshedToken.refreshToken
|
updateData.refreshToken = refreshedToken.refreshToken
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isMicrosoftProvider(credential.providerId)) {
|
|
||||||
updateData.refreshTokenExpiresAt = getMicrosoftRefreshTokenExpiry()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the token in the database
|
// Update the token in the database
|
||||||
await db.update(account).set(updateData).where(eq(account.id, credentialId))
|
await db.update(account).set(updateData).where(eq(account.id, credentialId))
|
||||||
|
|
||||||
@@ -286,10 +256,6 @@ export async function refreshAccessTokenIfNeeded(
|
|||||||
credentialId,
|
credentialId,
|
||||||
userId: credential.userId,
|
userId: credential.userId,
|
||||||
})
|
})
|
||||||
if (!accessTokenNeedsRefresh && accessToken) {
|
|
||||||
logger.info(`[${requestId}] Proactive refresh failed but access token still valid`)
|
|
||||||
return accessToken
|
|
||||||
}
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
} else if (!accessToken) {
|
} else if (!accessToken) {
|
||||||
@@ -311,27 +277,10 @@ export async function refreshTokenIfNeeded(
|
|||||||
credentialId: string
|
credentialId: string
|
||||||
): Promise<{ accessToken: string; refreshed: boolean }> {
|
): Promise<{ accessToken: string; refreshed: boolean }> {
|
||||||
// Decide if we should refresh: token missing OR expired
|
// Decide if we should refresh: token missing OR expired
|
||||||
const accessTokenExpiresAt = credential.accessTokenExpiresAt
|
const expiresAt = credential.accessTokenExpiresAt
|
||||||
const refreshTokenExpiresAt = credential.refreshTokenExpiresAt
|
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
|
const shouldRefresh =
|
||||||
// Check if access token needs refresh (missing or expired)
|
!!credential.refreshToken && (!credential.accessToken || (expiresAt && expiresAt <= now))
|
||||||
const accessTokenNeedsRefresh =
|
|
||||||
!!credential.refreshToken &&
|
|
||||||
(!credential.accessToken || (accessTokenExpiresAt && accessTokenExpiresAt <= now))
|
|
||||||
|
|
||||||
// Check if we should proactively refresh to prevent refresh token expiry
|
|
||||||
// This applies to Microsoft providers whose refresh tokens expire after 90 days of inactivity
|
|
||||||
const proactiveRefreshThreshold = new Date(
|
|
||||||
now.getTime() + PROACTIVE_REFRESH_THRESHOLD_DAYS * 24 * 60 * 60 * 1000
|
|
||||||
)
|
|
||||||
const refreshTokenNeedsProactiveRefresh =
|
|
||||||
!!credential.refreshToken &&
|
|
||||||
isMicrosoftProvider(credential.providerId) &&
|
|
||||||
refreshTokenExpiresAt &&
|
|
||||||
refreshTokenExpiresAt <= proactiveRefreshThreshold
|
|
||||||
|
|
||||||
const shouldRefresh = accessTokenNeedsRefresh || refreshTokenNeedsProactiveRefresh
|
|
||||||
|
|
||||||
// If token appears valid and present, return it directly
|
// If token appears valid and present, return it directly
|
||||||
if (!shouldRefresh) {
|
if (!shouldRefresh) {
|
||||||
@@ -344,17 +293,13 @@ export async function refreshTokenIfNeeded(
|
|||||||
|
|
||||||
if (!refreshResult) {
|
if (!refreshResult) {
|
||||||
logger.error(`[${requestId}] Failed to refresh token for credential`)
|
logger.error(`[${requestId}] Failed to refresh token for credential`)
|
||||||
if (!accessTokenNeedsRefresh && credential.accessToken) {
|
|
||||||
logger.info(`[${requestId}] Proactive refresh failed but access token still valid`)
|
|
||||||
return { accessToken: credential.accessToken, refreshed: false }
|
|
||||||
}
|
|
||||||
throw new Error('Failed to refresh token')
|
throw new Error('Failed to refresh token')
|
||||||
}
|
}
|
||||||
|
|
||||||
const { accessToken: refreshedToken, expiresIn, refreshToken: newRefreshToken } = refreshResult
|
const { accessToken: refreshedToken, expiresIn, refreshToken: newRefreshToken } = refreshResult
|
||||||
|
|
||||||
// Prepare update data
|
// Prepare update data
|
||||||
const updateData: Record<string, unknown> = {
|
const updateData: any = {
|
||||||
accessToken: refreshedToken,
|
accessToken: refreshedToken,
|
||||||
accessTokenExpiresAt: new Date(Date.now() + expiresIn * 1000), // Use provider's expiry
|
accessTokenExpiresAt: new Date(Date.now() + expiresIn * 1000), // Use provider's expiry
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
@@ -366,10 +311,6 @@ export async function refreshTokenIfNeeded(
|
|||||||
updateData.refreshToken = newRefreshToken
|
updateData.refreshToken = newRefreshToken
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isMicrosoftProvider(credential.providerId)) {
|
|
||||||
updateData.refreshTokenExpiresAt = getMicrosoftRefreshTokenExpiry()
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.update(account).set(updateData).where(eq(account.id, credentialId))
|
await db.update(account).set(updateData).where(eq(account.id, credentialId))
|
||||||
|
|
||||||
logger.info(`[${requestId}] Successfully refreshed access token`)
|
logger.info(`[${requestId}] Successfully refreshed access token`)
|
||||||
@@ -390,11 +331,6 @@ export async function refreshTokenIfNeeded(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!accessTokenNeedsRefresh && credential.accessToken) {
|
|
||||||
logger.info(`[${requestId}] Proactive refresh failed but access token still valid`)
|
|
||||||
return { accessToken: credential.accessToken, refreshed: false }
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.error(`[${requestId}] Refresh failed and no valid token found in DB`, error)
|
logger.error(`[${requestId}] Refresh failed and no valid token found in DB`, error)
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,8 +15,7 @@ const resetPasswordSchema = z.object({
|
|||||||
.max(100, 'Password must not exceed 100 characters')
|
.max(100, 'Password must not exceed 100 characters')
|
||||||
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
|
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
|
||||||
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
|
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
|
||||||
.regex(/[0-9]/, 'Password must contain at least one number')
|
.regex(/[0-9]/, 'Password must contain at least one number'),
|
||||||
.regex(/[^A-Za-z0-9]/, 'Password must contain at least one special character'),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { eq } from 'drizzle-orm'
|
|||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
|
|
||||||
const logger = createLogger('SSOProvidersRoute')
|
const logger = createLogger('SSO-Providers')
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { hasSSOAccess } from '@/lib/billing'
|
|||||||
import { env } from '@/lib/core/config/env'
|
import { env } from '@/lib/core/config/env'
|
||||||
import { REDACTED_MARKER } from '@/lib/core/security/redaction'
|
import { REDACTED_MARKER } from '@/lib/core/security/redaction'
|
||||||
|
|
||||||
const logger = createLogger('SSORegisterRoute')
|
const logger = createLogger('SSO-Register')
|
||||||
|
|
||||||
const mappingSchema = z
|
const mappingSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -43,10 +43,6 @@ const ssoRegistrationSchema = z.discriminatedUnion('providerType', [
|
|||||||
])
|
])
|
||||||
.default(['openid', 'profile', 'email']),
|
.default(['openid', 'profile', 'email']),
|
||||||
pkce: z.boolean().default(true),
|
pkce: z.boolean().default(true),
|
||||||
authorizationEndpoint: z.string().url().optional(),
|
|
||||||
tokenEndpoint: z.string().url().optional(),
|
|
||||||
userInfoEndpoint: z.string().url().optional(),
|
|
||||||
jwksEndpoint: z.string().url().optional(),
|
|
||||||
}),
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
providerType: z.literal('saml'),
|
providerType: z.literal('saml'),
|
||||||
@@ -68,10 +64,12 @@ const ssoRegistrationSchema = z.discriminatedUnion('providerType', [
|
|||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
// SSO plugin must be enabled in Better Auth
|
||||||
if (!env.SSO_ENABLED) {
|
if (!env.SSO_ENABLED) {
|
||||||
return NextResponse.json({ error: 'SSO is not enabled' }, { status: 400 })
|
return NextResponse.json({ error: 'SSO is not enabled' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check plan access (enterprise) or env var override
|
||||||
const session = await getSession()
|
const session = await getSession()
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
||||||
@@ -118,16 +116,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (providerType === 'oidc') {
|
if (providerType === 'oidc') {
|
||||||
const {
|
const { clientId, clientSecret, scopes, pkce } = body
|
||||||
clientId,
|
|
||||||
clientSecret,
|
|
||||||
scopes,
|
|
||||||
pkce,
|
|
||||||
authorizationEndpoint,
|
|
||||||
tokenEndpoint,
|
|
||||||
userInfoEndpoint,
|
|
||||||
jwksEndpoint,
|
|
||||||
} = body
|
|
||||||
|
|
||||||
const oidcConfig: any = {
|
const oidcConfig: any = {
|
||||||
clientId,
|
clientId,
|
||||||
@@ -138,102 +127,48 @@ export async function POST(request: NextRequest) {
|
|||||||
pkce: pkce ?? true,
|
pkce: pkce ?? true,
|
||||||
}
|
}
|
||||||
|
|
||||||
oidcConfig.authorizationEndpoint = authorizationEndpoint
|
// Add manual endpoints for providers that might need them
|
||||||
oidcConfig.tokenEndpoint = tokenEndpoint
|
// Common patterns for OIDC providers that don't support discovery properly
|
||||||
oidcConfig.userInfoEndpoint = userInfoEndpoint
|
|
||||||
oidcConfig.jwksEndpoint = jwksEndpoint
|
|
||||||
|
|
||||||
const needsDiscovery =
|
|
||||||
!oidcConfig.authorizationEndpoint || !oidcConfig.tokenEndpoint || !oidcConfig.jwksEndpoint
|
|
||||||
|
|
||||||
if (needsDiscovery) {
|
|
||||||
const discoveryUrl = `${issuer.replace(/\/$/, '')}/.well-known/openid-configuration`
|
|
||||||
try {
|
|
||||||
logger.info('Fetching OIDC discovery document for missing endpoints', {
|
|
||||||
discoveryUrl,
|
|
||||||
hasAuthEndpoint: !!oidcConfig.authorizationEndpoint,
|
|
||||||
hasTokenEndpoint: !!oidcConfig.tokenEndpoint,
|
|
||||||
hasJwksEndpoint: !!oidcConfig.jwksEndpoint,
|
|
||||||
})
|
|
||||||
|
|
||||||
const discoveryResponse = await fetch(discoveryUrl, {
|
|
||||||
headers: { Accept: 'application/json' },
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!discoveryResponse.ok) {
|
|
||||||
logger.error('Failed to fetch OIDC discovery document', {
|
|
||||||
status: discoveryResponse.status,
|
|
||||||
statusText: discoveryResponse.statusText,
|
|
||||||
})
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: `Failed to fetch OIDC discovery document from ${discoveryUrl}. Status: ${discoveryResponse.status}. Provide all endpoints explicitly or verify the issuer URL.`,
|
|
||||||
},
|
|
||||||
{ status: 400 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const discovery = await discoveryResponse.json()
|
|
||||||
|
|
||||||
oidcConfig.authorizationEndpoint =
|
|
||||||
oidcConfig.authorizationEndpoint || discovery.authorization_endpoint
|
|
||||||
oidcConfig.tokenEndpoint = oidcConfig.tokenEndpoint || discovery.token_endpoint
|
|
||||||
oidcConfig.userInfoEndpoint = oidcConfig.userInfoEndpoint || discovery.userinfo_endpoint
|
|
||||||
oidcConfig.jwksEndpoint = oidcConfig.jwksEndpoint || discovery.jwks_uri
|
|
||||||
|
|
||||||
logger.info('Merged OIDC endpoints (user-provided + discovery)', {
|
|
||||||
providerId,
|
|
||||||
issuer,
|
|
||||||
authorizationEndpoint: oidcConfig.authorizationEndpoint,
|
|
||||||
tokenEndpoint: oidcConfig.tokenEndpoint,
|
|
||||||
userInfoEndpoint: oidcConfig.userInfoEndpoint,
|
|
||||||
jwksEndpoint: oidcConfig.jwksEndpoint,
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error fetching OIDC discovery document', {
|
|
||||||
error: error instanceof Error ? error.message : 'Unknown error',
|
|
||||||
discoveryUrl,
|
|
||||||
})
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: `Failed to fetch OIDC discovery document from ${discoveryUrl}. Please verify the issuer URL is correct or provide all endpoints explicitly.`,
|
|
||||||
},
|
|
||||||
{ status: 400 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
logger.info('Using explicitly provided OIDC endpoints (all present)', {
|
|
||||||
providerId,
|
|
||||||
issuer,
|
|
||||||
authorizationEndpoint: oidcConfig.authorizationEndpoint,
|
|
||||||
tokenEndpoint: oidcConfig.tokenEndpoint,
|
|
||||||
userInfoEndpoint: oidcConfig.userInfoEndpoint,
|
|
||||||
jwksEndpoint: oidcConfig.jwksEndpoint,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!oidcConfig.authorizationEndpoint ||
|
issuer.includes('okta.com') ||
|
||||||
!oidcConfig.tokenEndpoint ||
|
issuer.includes('auth0.com') ||
|
||||||
!oidcConfig.jwksEndpoint
|
issuer.includes('identityserver')
|
||||||
) {
|
) {
|
||||||
const missing: string[] = []
|
const baseUrl = issuer.includes('/oauth2/default')
|
||||||
if (!oidcConfig.authorizationEndpoint) missing.push('authorizationEndpoint')
|
? issuer.replace('/oauth2/default', '')
|
||||||
if (!oidcConfig.tokenEndpoint) missing.push('tokenEndpoint')
|
: issuer.replace('/oauth', '').replace('/v2.0', '').replace('/oauth2', '')
|
||||||
if (!oidcConfig.jwksEndpoint) missing.push('jwksEndpoint')
|
|
||||||
|
|
||||||
logger.error('Missing required OIDC endpoints after discovery merge', {
|
// Okta-style endpoints
|
||||||
missing,
|
if (issuer.includes('okta.com')) {
|
||||||
authorizationEndpoint: oidcConfig.authorizationEndpoint,
|
oidcConfig.authorizationEndpoint = `${baseUrl}/oauth2/default/v1/authorize`
|
||||||
tokenEndpoint: oidcConfig.tokenEndpoint,
|
oidcConfig.tokenEndpoint = `${baseUrl}/oauth2/default/v1/token`
|
||||||
jwksEndpoint: oidcConfig.jwksEndpoint,
|
oidcConfig.userInfoEndpoint = `${baseUrl}/oauth2/default/v1/userinfo`
|
||||||
|
oidcConfig.jwksEndpoint = `${baseUrl}/oauth2/default/v1/keys`
|
||||||
|
}
|
||||||
|
// Auth0-style endpoints
|
||||||
|
else if (issuer.includes('auth0.com')) {
|
||||||
|
oidcConfig.authorizationEndpoint = `${baseUrl}/authorize`
|
||||||
|
oidcConfig.tokenEndpoint = `${baseUrl}/oauth/token`
|
||||||
|
oidcConfig.userInfoEndpoint = `${baseUrl}/userinfo`
|
||||||
|
oidcConfig.jwksEndpoint = `${baseUrl}/.well-known/jwks.json`
|
||||||
|
}
|
||||||
|
// Generic OIDC endpoints (IdentityServer, etc.)
|
||||||
|
else {
|
||||||
|
oidcConfig.authorizationEndpoint = `${baseUrl}/connect/authorize`
|
||||||
|
oidcConfig.tokenEndpoint = `${baseUrl}/connect/token`
|
||||||
|
oidcConfig.userInfoEndpoint = `${baseUrl}/connect/userinfo`
|
||||||
|
oidcConfig.jwksEndpoint = `${baseUrl}/.well-known/jwks`
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Using manual OIDC endpoints for provider', {
|
||||||
|
providerId,
|
||||||
|
provider: issuer.includes('okta.com')
|
||||||
|
? 'Okta'
|
||||||
|
: issuer.includes('auth0.com')
|
||||||
|
? 'Auth0'
|
||||||
|
: 'Generic',
|
||||||
|
authEndpoint: oidcConfig.authorizationEndpoint,
|
||||||
})
|
})
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: `Missing required OIDC endpoints: ${missing.join(', ')}. Please provide these explicitly or verify the issuer supports OIDC discovery.`,
|
|
||||||
},
|
|
||||||
{ status: 400 }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
providerConfig.oidcConfig = oidcConfig
|
providerConfig.oidcConfig = oidcConfig
|
||||||
|
|||||||
@@ -104,11 +104,17 @@ export async function POST(req: NextRequest) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Build execution params starting with LLM-provided arguments
|
// Build execution params starting with LLM-provided arguments
|
||||||
// Resolve all {{ENV_VAR}} references in the arguments (deep for nested objects)
|
// Resolve all {{ENV_VAR}} references in the arguments
|
||||||
const executionParams: Record<string, any> = resolveEnvVarReferences(
|
const executionParams: Record<string, any> = resolveEnvVarReferences(
|
||||||
toolArgs,
|
toolArgs,
|
||||||
decryptedEnvVars,
|
decryptedEnvVars,
|
||||||
{ deep: true }
|
{
|
||||||
|
resolveExactMatch: true,
|
||||||
|
allowEmbedded: true,
|
||||||
|
trimKeys: true,
|
||||||
|
onMissing: 'keep',
|
||||||
|
deep: true,
|
||||||
|
}
|
||||||
) as Record<string, any>
|
) as Record<string, any>
|
||||||
|
|
||||||
logger.info(`[${tracker.requestId}] Resolved env var references in arguments`, {
|
logger.info(`[${tracker.requestId}] Resolved env var references in arguments`, {
|
||||||
@@ -218,7 +224,7 @@ export async function POST(req: NextRequest) {
|
|||||||
hasApiKey: !!executionParams.apiKey,
|
hasApiKey: !!executionParams.apiKey,
|
||||||
})
|
})
|
||||||
|
|
||||||
const result = await executeTool(resolvedToolName, executionParams)
|
const result = await executeTool(resolvedToolName, executionParams, true)
|
||||||
|
|
||||||
logger.info(`[${tracker.requestId}] Tool execution complete`, {
|
logger.info(`[${tracker.requestId}] Tool execution complete`, {
|
||||||
toolName,
|
toolName,
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { db } from '@sim/db'
|
import { db } from '@sim/db'
|
||||||
import { templateCreators } from '@sim/db/schema'
|
import { templateCreators, user } from '@sim/db/schema'
|
||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { eq } from 'drizzle-orm'
|
import { eq } from 'drizzle-orm'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
import { verifyEffectiveSuperUser } from '@/lib/templates/permissions'
|
|
||||||
|
|
||||||
const logger = createLogger('CreatorVerificationAPI')
|
const logger = createLogger('CreatorVerificationAPI')
|
||||||
|
|
||||||
@@ -24,8 +23,9 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if user is a super user
|
// Check if user is a super user
|
||||||
const { effectiveSuperUser } = await verifyEffectiveSuperUser(session.user.id)
|
const currentUser = await db.select().from(user).where(eq(user.id, session.user.id)).limit(1)
|
||||||
if (!effectiveSuperUser) {
|
|
||||||
|
if (!currentUser[0]?.isSuperUser) {
|
||||||
logger.warn(`[${requestId}] Non-super user attempted to verify creator: ${id}`)
|
logger.warn(`[${requestId}] Non-super user attempted to verify creator: ${id}`)
|
||||||
return NextResponse.json({ error: 'Only super users can verify creators' }, { status: 403 })
|
return NextResponse.json({ error: 'Only super users can verify creators' }, { status: 403 })
|
||||||
}
|
}
|
||||||
@@ -76,8 +76,9 @@ export async function DELETE(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if user is a super user
|
// Check if user is a super user
|
||||||
const { effectiveSuperUser } = await verifyEffectiveSuperUser(session.user.id)
|
const currentUser = await db.select().from(user).where(eq(user.id, session.user.id)).limit(1)
|
||||||
if (!effectiveSuperUser) {
|
|
||||||
|
if (!currentUser[0]?.isSuperUser) {
|
||||||
logger.warn(`[${requestId}] Non-super user attempted to unverify creator: ${id}`)
|
logger.warn(`[${requestId}] Non-super user attempted to unverify creator: ${id}`)
|
||||||
return NextResponse.json({ error: 'Only super users can unverify creators' }, { status: 403 })
|
return NextResponse.json({ error: 'Only super users can unverify creators' }, { status: 403 })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,9 @@ import { createLogger } from '@sim/logger'
|
|||||||
import binaryExtensionsList from 'binary-extensions'
|
import binaryExtensionsList from 'binary-extensions'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||||
import { secureFetchWithPinnedIP, validateUrlWithDNS } from '@/lib/core/security/input-validation'
|
import { createPinnedUrl, validateUrlWithDNS } from '@/lib/core/security/input-validation'
|
||||||
import { isSupportedFileType, parseFile } from '@/lib/file-parsers'
|
import { isSupportedFileType, parseFile } from '@/lib/file-parsers'
|
||||||
import { isUsingCloudStorage, type StorageContext, StorageService } from '@/lib/uploads'
|
import { isUsingCloudStorage, type StorageContext, StorageService } from '@/lib/uploads'
|
||||||
import { uploadExecutionFile } from '@/lib/uploads/contexts/execution'
|
|
||||||
import { UPLOAD_DIR_SERVER } from '@/lib/uploads/core/setup.server'
|
import { UPLOAD_DIR_SERVER } from '@/lib/uploads/core/setup.server'
|
||||||
import { getFileMetadataByKey } from '@/lib/uploads/server/metadata'
|
import { getFileMetadataByKey } from '@/lib/uploads/server/metadata'
|
||||||
import {
|
import {
|
||||||
@@ -22,7 +21,6 @@ import {
|
|||||||
} from '@/lib/uploads/utils/file-utils'
|
} from '@/lib/uploads/utils/file-utils'
|
||||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||||
import { verifyFileAccess } from '@/app/api/files/authorization'
|
import { verifyFileAccess } from '@/app/api/files/authorization'
|
||||||
import type { UserFile } from '@/executor/types'
|
|
||||||
import '@/lib/uploads/core/setup.server'
|
import '@/lib/uploads/core/setup.server'
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
@@ -32,12 +30,6 @@ const logger = createLogger('FilesParseAPI')
|
|||||||
const MAX_DOWNLOAD_SIZE_BYTES = 100 * 1024 * 1024 // 100 MB
|
const MAX_DOWNLOAD_SIZE_BYTES = 100 * 1024 * 1024 // 100 MB
|
||||||
const DOWNLOAD_TIMEOUT_MS = 30000 // 30 seconds
|
const DOWNLOAD_TIMEOUT_MS = 30000 // 30 seconds
|
||||||
|
|
||||||
interface ExecutionContext {
|
|
||||||
workspaceId: string
|
|
||||||
workflowId: string
|
|
||||||
executionId: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ParseResult {
|
interface ParseResult {
|
||||||
success: boolean
|
success: boolean
|
||||||
content?: string
|
content?: string
|
||||||
@@ -45,7 +37,6 @@ interface ParseResult {
|
|||||||
filePath: string
|
filePath: string
|
||||||
originalName?: string // Original filename from database (for workspace files)
|
originalName?: string // Original filename from database (for workspace files)
|
||||||
viewerUrl?: string | null // Viewer URL for the file if available
|
viewerUrl?: string | null // Viewer URL for the file if available
|
||||||
userFile?: UserFile // UserFile object for the raw file
|
|
||||||
metadata?: {
|
metadata?: {
|
||||||
fileType: string
|
fileType: string
|
||||||
size: number
|
size: number
|
||||||
@@ -79,45 +70,27 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const userId = authResult.userId
|
const userId = authResult.userId
|
||||||
const requestData = await request.json()
|
const requestData = await request.json()
|
||||||
const { filePath, fileType, workspaceId, workflowId, executionId } = requestData
|
const { filePath, fileType, workspaceId } = requestData
|
||||||
|
|
||||||
if (!filePath || (typeof filePath === 'string' && filePath.trim() === '')) {
|
if (!filePath || (typeof filePath === 'string' && filePath.trim() === '')) {
|
||||||
return NextResponse.json({ success: false, error: 'No file path provided' }, { status: 400 })
|
return NextResponse.json({ success: false, error: 'No file path provided' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build execution context if all required fields are present
|
logger.info('File parse request received:', { filePath, fileType, workspaceId, userId })
|
||||||
const executionContext: ExecutionContext | undefined =
|
|
||||||
workspaceId && workflowId && executionId
|
|
||||||
? { workspaceId, workflowId, executionId }
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
logger.info('File parse request received:', {
|
|
||||||
filePath,
|
|
||||||
fileType,
|
|
||||||
workspaceId,
|
|
||||||
userId,
|
|
||||||
hasExecutionContext: !!executionContext,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (Array.isArray(filePath)) {
|
if (Array.isArray(filePath)) {
|
||||||
const results = []
|
const results = []
|
||||||
for (const singlePath of filePath) {
|
for (const path of filePath) {
|
||||||
if (!singlePath || (typeof singlePath === 'string' && singlePath.trim() === '')) {
|
if (!path || (typeof path === 'string' && path.trim() === '')) {
|
||||||
results.push({
|
results.push({
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Empty file path in array',
|
error: 'Empty file path in array',
|
||||||
filePath: singlePath || '',
|
filePath: path || '',
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await parseFileSingle(
|
const result = await parseFileSingle(path, fileType, workspaceId, userId)
|
||||||
singlePath,
|
|
||||||
fileType,
|
|
||||||
workspaceId,
|
|
||||||
userId,
|
|
||||||
executionContext
|
|
||||||
)
|
|
||||||
if (result.metadata) {
|
if (result.metadata) {
|
||||||
result.metadata.processingTime = Date.now() - startTime
|
result.metadata.processingTime = Date.now() - startTime
|
||||||
}
|
}
|
||||||
@@ -133,7 +106,6 @@ export async function POST(request: NextRequest) {
|
|||||||
fileType: result.metadata?.fileType || 'application/octet-stream',
|
fileType: result.metadata?.fileType || 'application/octet-stream',
|
||||||
size: result.metadata?.size || 0,
|
size: result.metadata?.size || 0,
|
||||||
binary: false,
|
binary: false,
|
||||||
file: result.userFile,
|
|
||||||
},
|
},
|
||||||
filePath: result.filePath,
|
filePath: result.filePath,
|
||||||
viewerUrl: result.viewerUrl,
|
viewerUrl: result.viewerUrl,
|
||||||
@@ -149,7 +121,7 @@ export async function POST(request: NextRequest) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await parseFileSingle(filePath, fileType, workspaceId, userId, executionContext)
|
const result = await parseFileSingle(filePath, fileType, workspaceId, userId)
|
||||||
|
|
||||||
if (result.metadata) {
|
if (result.metadata) {
|
||||||
result.metadata.processingTime = Date.now() - startTime
|
result.metadata.processingTime = Date.now() - startTime
|
||||||
@@ -165,7 +137,6 @@ export async function POST(request: NextRequest) {
|
|||||||
fileType: result.metadata?.fileType || 'application/octet-stream',
|
fileType: result.metadata?.fileType || 'application/octet-stream',
|
||||||
size: result.metadata?.size || 0,
|
size: result.metadata?.size || 0,
|
||||||
binary: false,
|
binary: false,
|
||||||
file: result.userFile,
|
|
||||||
},
|
},
|
||||||
filePath: result.filePath,
|
filePath: result.filePath,
|
||||||
viewerUrl: result.viewerUrl,
|
viewerUrl: result.viewerUrl,
|
||||||
@@ -193,8 +164,7 @@ async function parseFileSingle(
|
|||||||
filePath: string,
|
filePath: string,
|
||||||
fileType: string,
|
fileType: string,
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
userId: string,
|
userId: string
|
||||||
executionContext?: ExecutionContext
|
|
||||||
): Promise<ParseResult> {
|
): Promise<ParseResult> {
|
||||||
logger.info('Parsing file:', filePath)
|
logger.info('Parsing file:', filePath)
|
||||||
|
|
||||||
@@ -216,18 +186,18 @@ async function parseFileSingle(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (filePath.includes('/api/files/serve/')) {
|
if (filePath.includes('/api/files/serve/')) {
|
||||||
return handleCloudFile(filePath, fileType, undefined, userId, executionContext)
|
return handleCloudFile(filePath, fileType, undefined, userId)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filePath.startsWith('http://') || filePath.startsWith('https://')) {
|
if (filePath.startsWith('http://') || filePath.startsWith('https://')) {
|
||||||
return handleExternalUrl(filePath, fileType, workspaceId, userId, executionContext)
|
return handleExternalUrl(filePath, fileType, workspaceId, userId)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isUsingCloudStorage()) {
|
if (isUsingCloudStorage()) {
|
||||||
return handleCloudFile(filePath, fileType, undefined, userId, executionContext)
|
return handleCloudFile(filePath, fileType, undefined, userId)
|
||||||
}
|
}
|
||||||
|
|
||||||
return handleLocalFile(filePath, fileType, userId, executionContext)
|
return handleLocalFile(filePath, fileType, userId)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -260,14 +230,12 @@ function validateFilePath(filePath: string): { isValid: boolean; error?: string
|
|||||||
/**
|
/**
|
||||||
* Handle external URL
|
* Handle external URL
|
||||||
* If workspaceId is provided, checks if file already exists and saves to workspace if not
|
* If workspaceId is provided, checks if file already exists and saves to workspace if not
|
||||||
* If executionContext is provided, also stores the file in execution storage and returns UserFile
|
|
||||||
*/
|
*/
|
||||||
async function handleExternalUrl(
|
async function handleExternalUrl(
|
||||||
url: string,
|
url: string,
|
||||||
fileType: string,
|
fileType: string,
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
userId: string,
|
userId: string
|
||||||
executionContext?: ExecutionContext
|
|
||||||
): Promise<ParseResult> {
|
): Promise<ParseResult> {
|
||||||
try {
|
try {
|
||||||
logger.info('Fetching external URL:', url)
|
logger.info('Fetching external URL:', url)
|
||||||
@@ -344,13 +312,17 @@ async function handleExternalUrl(
|
|||||||
|
|
||||||
if (existingFile) {
|
if (existingFile) {
|
||||||
const storageFilePath = `/api/files/serve/${existingFile.key}`
|
const storageFilePath = `/api/files/serve/${existingFile.key}`
|
||||||
return handleCloudFile(storageFilePath, fileType, 'workspace', userId, executionContext)
|
return handleCloudFile(storageFilePath, fileType, 'workspace', userId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await secureFetchWithPinnedIP(url, urlValidation.resolvedIP!, {
|
const pinnedUrl = createPinnedUrl(url, urlValidation.resolvedIP!)
|
||||||
timeout: DOWNLOAD_TIMEOUT_MS,
|
const response = await fetch(pinnedUrl, {
|
||||||
|
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
|
||||||
|
headers: {
|
||||||
|
Host: urlValidation.originalHostname!,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Failed to fetch URL: ${response.status} ${response.statusText}`)
|
throw new Error(`Failed to fetch URL: ${response.status} ${response.statusText}`)
|
||||||
@@ -369,19 +341,6 @@ async function handleExternalUrl(
|
|||||||
|
|
||||||
logger.info(`Downloaded file from URL: ${url}, size: ${buffer.length} bytes`)
|
logger.info(`Downloaded file from URL: ${url}, size: ${buffer.length} bytes`)
|
||||||
|
|
||||||
let userFile: UserFile | undefined
|
|
||||||
const mimeType = response.headers.get('content-type') || getMimeTypeFromExtension(extension)
|
|
||||||
|
|
||||||
if (executionContext) {
|
|
||||||
try {
|
|
||||||
userFile = await uploadExecutionFile(executionContext, buffer, filename, mimeType, userId)
|
|
||||||
logger.info(`Stored file in execution storage: ${filename}`, { key: userFile.key })
|
|
||||||
} catch (uploadError) {
|
|
||||||
logger.warn(`Failed to store file in execution storage:`, uploadError)
|
|
||||||
// Continue without userFile - parsing can still work
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldCheckWorkspace) {
|
if (shouldCheckWorkspace) {
|
||||||
try {
|
try {
|
||||||
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
|
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
|
||||||
@@ -394,6 +353,8 @@ async function handleExternalUrl(
|
|||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
const { uploadWorkspaceFile } = await import('@/lib/uploads/contexts/workspace')
|
const { uploadWorkspaceFile } = await import('@/lib/uploads/contexts/workspace')
|
||||||
|
const mimeType =
|
||||||
|
response.headers.get('content-type') || getMimeTypeFromExtension(extension)
|
||||||
await uploadWorkspaceFile(workspaceId, userId, buffer, filename, mimeType)
|
await uploadWorkspaceFile(workspaceId, userId, buffer, filename, mimeType)
|
||||||
logger.info(`Saved URL file to workspace storage: ${filename}`)
|
logger.info(`Saved URL file to workspace storage: ${filename}`)
|
||||||
}
|
}
|
||||||
@@ -402,23 +363,17 @@ async function handleExternalUrl(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let parseResult: ParseResult
|
|
||||||
if (extension === 'pdf') {
|
if (extension === 'pdf') {
|
||||||
parseResult = await handlePdfBuffer(buffer, filename, fileType, url)
|
return await handlePdfBuffer(buffer, filename, fileType, url)
|
||||||
} else if (extension === 'csv') {
|
}
|
||||||
parseResult = await handleCsvBuffer(buffer, filename, fileType, url)
|
if (extension === 'csv') {
|
||||||
} else if (isSupportedFileType(extension)) {
|
return await handleCsvBuffer(buffer, filename, fileType, url)
|
||||||
parseResult = await handleGenericTextBuffer(buffer, filename, extension, fileType, url)
|
}
|
||||||
} else {
|
if (isSupportedFileType(extension)) {
|
||||||
parseResult = handleGenericBuffer(buffer, filename, extension, fileType)
|
return await handleGenericTextBuffer(buffer, filename, extension, fileType, url)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attach userFile to the result
|
return handleGenericBuffer(buffer, filename, extension, fileType)
|
||||||
if (userFile) {
|
|
||||||
parseResult.userFile = userFile
|
|
||||||
}
|
|
||||||
|
|
||||||
return parseResult
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Error handling external URL ${url}:`, error)
|
logger.error(`Error handling external URL ${url}:`, error)
|
||||||
return {
|
return {
|
||||||
@@ -431,15 +386,12 @@ async function handleExternalUrl(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle file stored in cloud storage
|
* Handle file stored in cloud storage
|
||||||
* If executionContext is provided and file is not already from execution storage,
|
|
||||||
* copies the file to execution storage and returns UserFile
|
|
||||||
*/
|
*/
|
||||||
async function handleCloudFile(
|
async function handleCloudFile(
|
||||||
filePath: string,
|
filePath: string,
|
||||||
fileType: string,
|
fileType: string,
|
||||||
explicitContext: string | undefined,
|
explicitContext: string | undefined,
|
||||||
userId: string,
|
userId: string
|
||||||
executionContext?: ExecutionContext
|
|
||||||
): Promise<ParseResult> {
|
): Promise<ParseResult> {
|
||||||
try {
|
try {
|
||||||
const cloudKey = extractStorageKey(filePath)
|
const cloudKey = extractStorageKey(filePath)
|
||||||
@@ -486,7 +438,6 @@ async function handleCloudFile(
|
|||||||
|
|
||||||
const filename = originalFilename || cloudKey.split('/').pop() || cloudKey
|
const filename = originalFilename || cloudKey.split('/').pop() || cloudKey
|
||||||
const extension = path.extname(filename).toLowerCase().substring(1)
|
const extension = path.extname(filename).toLowerCase().substring(1)
|
||||||
const mimeType = getMimeTypeFromExtension(extension)
|
|
||||||
|
|
||||||
const normalizedFilePath = `/api/files/serve/${encodeURIComponent(cloudKey)}?context=${context}`
|
const normalizedFilePath = `/api/files/serve/${encodeURIComponent(cloudKey)}?context=${context}`
|
||||||
let workspaceIdFromKey: string | undefined
|
let workspaceIdFromKey: string | undefined
|
||||||
@@ -502,39 +453,6 @@ async function handleCloudFile(
|
|||||||
|
|
||||||
const viewerUrl = getViewerUrl(cloudKey, workspaceIdFromKey)
|
const viewerUrl = getViewerUrl(cloudKey, workspaceIdFromKey)
|
||||||
|
|
||||||
// Store file in execution storage if executionContext is provided
|
|
||||||
let userFile: UserFile | undefined
|
|
||||||
|
|
||||||
if (executionContext) {
|
|
||||||
// If file is already from execution context, create UserFile reference without re-uploading
|
|
||||||
if (context === 'execution') {
|
|
||||||
userFile = {
|
|
||||||
id: `file_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
|
|
||||||
name: filename,
|
|
||||||
url: normalizedFilePath,
|
|
||||||
size: fileBuffer.length,
|
|
||||||
type: mimeType,
|
|
||||||
key: cloudKey,
|
|
||||||
context: 'execution',
|
|
||||||
}
|
|
||||||
logger.info(`Created UserFile reference for existing execution file: ${filename}`)
|
|
||||||
} else {
|
|
||||||
// Copy from workspace/other storage to execution storage
|
|
||||||
try {
|
|
||||||
userFile = await uploadExecutionFile(
|
|
||||||
executionContext,
|
|
||||||
fileBuffer,
|
|
||||||
filename,
|
|
||||||
mimeType,
|
|
||||||
userId
|
|
||||||
)
|
|
||||||
logger.info(`Copied file to execution storage: ${filename}`, { key: userFile.key })
|
|
||||||
} catch (uploadError) {
|
|
||||||
logger.warn(`Failed to copy file to execution storage:`, uploadError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let parseResult: ParseResult
|
let parseResult: ParseResult
|
||||||
if (extension === 'pdf') {
|
if (extension === 'pdf') {
|
||||||
parseResult = await handlePdfBuffer(fileBuffer, filename, fileType, normalizedFilePath)
|
parseResult = await handlePdfBuffer(fileBuffer, filename, fileType, normalizedFilePath)
|
||||||
@@ -559,11 +477,6 @@ async function handleCloudFile(
|
|||||||
|
|
||||||
parseResult.viewerUrl = viewerUrl
|
parseResult.viewerUrl = viewerUrl
|
||||||
|
|
||||||
// Attach userFile to the result
|
|
||||||
if (userFile) {
|
|
||||||
parseResult.userFile = userFile
|
|
||||||
}
|
|
||||||
|
|
||||||
return parseResult
|
return parseResult
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Error handling cloud file ${filePath}:`, error)
|
logger.error(`Error handling cloud file ${filePath}:`, error)
|
||||||
@@ -587,8 +500,7 @@ async function handleCloudFile(
|
|||||||
async function handleLocalFile(
|
async function handleLocalFile(
|
||||||
filePath: string,
|
filePath: string,
|
||||||
fileType: string,
|
fileType: string,
|
||||||
userId: string,
|
userId: string
|
||||||
executionContext?: ExecutionContext
|
|
||||||
): Promise<ParseResult> {
|
): Promise<ParseResult> {
|
||||||
try {
|
try {
|
||||||
const filename = filePath.split('/').pop() || filePath
|
const filename = filePath.split('/').pop() || filePath
|
||||||
@@ -628,32 +540,13 @@ async function handleLocalFile(
|
|||||||
const hash = createHash('md5').update(fileBuffer).digest('hex')
|
const hash = createHash('md5').update(fileBuffer).digest('hex')
|
||||||
|
|
||||||
const extension = path.extname(filename).toLowerCase().substring(1)
|
const extension = path.extname(filename).toLowerCase().substring(1)
|
||||||
const mimeType = fileType || getMimeTypeFromExtension(extension)
|
|
||||||
|
|
||||||
// Store file in execution storage if executionContext is provided
|
|
||||||
let userFile: UserFile | undefined
|
|
||||||
if (executionContext) {
|
|
||||||
try {
|
|
||||||
userFile = await uploadExecutionFile(
|
|
||||||
executionContext,
|
|
||||||
fileBuffer,
|
|
||||||
filename,
|
|
||||||
mimeType,
|
|
||||||
userId
|
|
||||||
)
|
|
||||||
logger.info(`Stored local file in execution storage: ${filename}`, { key: userFile.key })
|
|
||||||
} catch (uploadError) {
|
|
||||||
logger.warn(`Failed to store local file in execution storage:`, uploadError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
content: result.content,
|
content: result.content,
|
||||||
filePath,
|
filePath,
|
||||||
userFile,
|
|
||||||
metadata: {
|
metadata: {
|
||||||
fileType: mimeType,
|
fileType: fileType || getMimeTypeFromExtension(extension),
|
||||||
size: stats.size,
|
size: stats.size,
|
||||||
hash,
|
hash,
|
||||||
processingTime: 0,
|
processingTime: 0,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { preprocessExecution } from '@/lib/execution/preprocessing'
|
|||||||
import { LoggingSession } from '@/lib/logs/execution/logging-session'
|
import { LoggingSession } from '@/lib/logs/execution/logging-session'
|
||||||
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
|
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
|
||||||
import { createStreamingResponse } from '@/lib/workflows/streaming/streaming'
|
import { createStreamingResponse } from '@/lib/workflows/streaming/streaming'
|
||||||
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
|
import { isValidStartBlockType } from '@/lib/workflows/triggers/start-block-types'
|
||||||
import { setFormAuthCookie, validateFormAuth } from '@/app/api/form/utils'
|
import { setFormAuthCookie, validateFormAuth } from '@/app/api/form/utils'
|
||||||
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
|
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ async function getWorkflowInputSchema(workflowId: string): Promise<any[]> {
|
|||||||
.from(workflowBlocks)
|
.from(workflowBlocks)
|
||||||
.where(eq(workflowBlocks.workflowId, workflowId))
|
.where(eq(workflowBlocks.workflowId, workflowId))
|
||||||
|
|
||||||
const startBlock = blocks.find((block) => isInputDefinitionTrigger(block.type))
|
const startBlock = blocks.find((block) => isValidStartBlockType(block.type))
|
||||||
|
|
||||||
if (!startBlock) {
|
if (!startBlock) {
|
||||||
return []
|
return []
|
||||||
|
|||||||
@@ -84,14 +84,6 @@ vi.mock('@/lib/execution/isolated-vm', () => ({
|
|||||||
|
|
||||||
vi.mock('@sim/logger', () => loggerMock)
|
vi.mock('@sim/logger', () => loggerMock)
|
||||||
|
|
||||||
vi.mock('@/lib/auth/hybrid', () => ({
|
|
||||||
checkInternalAuth: vi.fn().mockResolvedValue({
|
|
||||||
success: true,
|
|
||||||
userId: 'user-123',
|
|
||||||
authType: 'internal_jwt',
|
|
||||||
}),
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@/lib/execution/e2b', () => ({
|
vi.mock('@/lib/execution/e2b', () => ({
|
||||||
executeInE2B: vi.fn(),
|
executeInE2B: vi.fn(),
|
||||||
}))
|
}))
|
||||||
@@ -118,24 +110,6 @@ describe('Function Execute API Route', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('Security Tests', () => {
|
describe('Security Tests', () => {
|
||||||
it('should reject unauthorized requests', async () => {
|
|
||||||
const { checkInternalAuth } = await import('@/lib/auth/hybrid')
|
|
||||||
vi.mocked(checkInternalAuth).mockResolvedValueOnce({
|
|
||||||
success: false,
|
|
||||||
error: 'Unauthorized',
|
|
||||||
})
|
|
||||||
|
|
||||||
const req = createMockRequest('POST', {
|
|
||||||
code: 'return "test"',
|
|
||||||
})
|
|
||||||
|
|
||||||
const response = await POST(req)
|
|
||||||
const data = await response.json()
|
|
||||||
|
|
||||||
expect(response.status).toBe(401)
|
|
||||||
expect(data).toHaveProperty('error', 'Unauthorized')
|
|
||||||
})
|
|
||||||
|
|
||||||
it.concurrent('should use isolated-vm for secure sandboxed execution', async () => {
|
it.concurrent('should use isolated-vm for secure sandboxed execution', async () => {
|
||||||
const req = createMockRequest('POST', {
|
const req = createMockRequest('POST', {
|
||||||
code: 'return "test"',
|
code: 'return "test"',
|
||||||
@@ -302,11 +276,8 @@ describe('Function Execute API Route', () => {
|
|||||||
it.concurrent('should resolve tag variables with <tag_name> syntax', async () => {
|
it.concurrent('should resolve tag variables with <tag_name> syntax', async () => {
|
||||||
const req = createMockRequest('POST', {
|
const req = createMockRequest('POST', {
|
||||||
code: 'return <email>',
|
code: 'return <email>',
|
||||||
blockData: {
|
params: {
|
||||||
'block-123': { id: '123', subject: 'Test Email' },
|
email: { id: '123', subject: 'Test Email' },
|
||||||
},
|
|
||||||
blockNameMapping: {
|
|
||||||
email: 'block-123',
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -334,13 +305,9 @@ describe('Function Execute API Route', () => {
|
|||||||
it.concurrent('should only match valid variable names in angle brackets', async () => {
|
it.concurrent('should only match valid variable names in angle brackets', async () => {
|
||||||
const req = createMockRequest('POST', {
|
const req = createMockRequest('POST', {
|
||||||
code: 'return <validVar> + "<invalid@email.com>" + <another_valid>',
|
code: 'return <validVar> + "<invalid@email.com>" + <another_valid>',
|
||||||
blockData: {
|
params: {
|
||||||
'block-1': 'hello',
|
validVar: 'hello',
|
||||||
'block-2': 'world',
|
another_valid: 'world',
|
||||||
},
|
|
||||||
blockNameMapping: {
|
|
||||||
validvar: 'block-1',
|
|
||||||
another_valid: 'block-2',
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -354,22 +321,28 @@ describe('Function Execute API Route', () => {
|
|||||||
it.concurrent(
|
it.concurrent(
|
||||||
'should handle Gmail webhook data with email addresses containing angle brackets',
|
'should handle Gmail webhook data with email addresses containing angle brackets',
|
||||||
async () => {
|
async () => {
|
||||||
const emailData = {
|
const gmailData = {
|
||||||
|
email: {
|
||||||
id: '123',
|
id: '123',
|
||||||
from: 'Waleed Latif <waleed@sim.ai>',
|
from: 'Waleed Latif <waleed@sim.ai>',
|
||||||
to: 'User <user@example.com>',
|
to: 'User <user@example.com>',
|
||||||
subject: 'Test Email',
|
subject: 'Test Email',
|
||||||
bodyText: 'Hello world',
|
bodyText: 'Hello world',
|
||||||
|
},
|
||||||
|
rawEmail: {
|
||||||
|
id: '123',
|
||||||
|
payload: {
|
||||||
|
headers: [
|
||||||
|
{ name: 'From', value: 'Waleed Latif <waleed@sim.ai>' },
|
||||||
|
{ name: 'To', value: 'User <user@example.com>' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const req = createMockRequest('POST', {
|
const req = createMockRequest('POST', {
|
||||||
code: 'return <email>',
|
code: 'return <email>',
|
||||||
blockData: {
|
params: gmailData,
|
||||||
'block-email': emailData,
|
|
||||||
},
|
|
||||||
blockNameMapping: {
|
|
||||||
email: 'block-email',
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const response = await POST(req)
|
const response = await POST(req)
|
||||||
@@ -383,20 +356,17 @@ describe('Function Execute API Route', () => {
|
|||||||
it.concurrent(
|
it.concurrent(
|
||||||
'should properly serialize complex email objects with special characters',
|
'should properly serialize complex email objects with special characters',
|
||||||
async () => {
|
async () => {
|
||||||
const emailData = {
|
const complexEmailData = {
|
||||||
|
email: {
|
||||||
from: 'Test User <test@example.com>',
|
from: 'Test User <test@example.com>',
|
||||||
bodyHtml: '<div>HTML content with "quotes" and \'apostrophes\'</div>',
|
bodyHtml: '<div>HTML content with "quotes" and \'apostrophes\'</div>',
|
||||||
bodyText: 'Text with\nnewlines\tand\ttabs',
|
bodyText: 'Text with\nnewlines\tand\ttabs',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const req = createMockRequest('POST', {
|
const req = createMockRequest('POST', {
|
||||||
code: 'return <email>',
|
code: 'return <email>',
|
||||||
blockData: {
|
params: complexEmailData,
|
||||||
'block-email': emailData,
|
|
||||||
},
|
|
||||||
blockNameMapping: {
|
|
||||||
email: 'block-email',
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const response = await POST(req)
|
const response = await POST(req)
|
||||||
@@ -549,7 +519,10 @@ describe('Function Execute API Route', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it.concurrent('should handle JSON serialization edge cases', async () => {
|
it.concurrent('should handle JSON serialization edge cases', async () => {
|
||||||
const complexData = {
|
const req = createMockRequest('POST', {
|
||||||
|
code: 'return <complexData>',
|
||||||
|
params: {
|
||||||
|
complexData: {
|
||||||
special: 'chars"with\'quotes',
|
special: 'chars"with\'quotes',
|
||||||
unicode: '🎉 Unicode content',
|
unicode: '🎉 Unicode content',
|
||||||
nested: {
|
nested: {
|
||||||
@@ -557,15 +530,7 @@ describe('Function Execute API Route', () => {
|
|||||||
value: 'test',
|
value: 'test',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
|
||||||
|
|
||||||
const req = createMockRequest('POST', {
|
|
||||||
code: 'return <complexData>',
|
|
||||||
blockData: {
|
|
||||||
'block-complex': complexData,
|
|
||||||
},
|
},
|
||||||
blockNameMapping: {
|
|
||||||
complexdata: 'block-complex',
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
|
||||||
import { isE2bEnabled } from '@/lib/core/config/feature-flags'
|
import { isE2bEnabled } from '@/lib/core/config/feature-flags'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
import { executeInE2B } from '@/lib/execution/e2b'
|
import { executeInE2B } from '@/lib/execution/e2b'
|
||||||
import { executeInIsolatedVM } from '@/lib/execution/isolated-vm'
|
import { executeInIsolatedVM } from '@/lib/execution/isolated-vm'
|
||||||
import { CodeLanguage, DEFAULT_CODE_LANGUAGE, isValidCodeLanguage } from '@/lib/execution/languages'
|
import { CodeLanguage, DEFAULT_CODE_LANGUAGE, isValidCodeLanguage } from '@/lib/execution/languages'
|
||||||
import { escapeRegExp, normalizeName, REFERENCE } from '@/executor/constants'
|
import { escapeRegExp, normalizeName, REFERENCE } from '@/executor/constants'
|
||||||
import { type OutputSchema, resolveBlockReference } from '@/executor/utils/block-reference'
|
|
||||||
import {
|
import {
|
||||||
createEnvVarPattern,
|
createEnvVarPattern,
|
||||||
createWorkflowVariablePattern,
|
createWorkflowVariablePattern,
|
||||||
|
resolveEnvVarReferences,
|
||||||
} from '@/executor/utils/reference-validation'
|
} from '@/executor/utils/reference-validation'
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
export const runtime = 'nodejs'
|
export const runtime = 'nodejs'
|
||||||
@@ -19,8 +18,8 @@ export const MAX_DURATION = 210
|
|||||||
|
|
||||||
const logger = createLogger('FunctionExecuteAPI')
|
const logger = createLogger('FunctionExecuteAPI')
|
||||||
|
|
||||||
const E2B_JS_WRAPPER_LINES = 3
|
const E2B_JS_WRAPPER_LINES = 3 // Lines before user code: ';(async () => {', ' try {', ' const __sim_result = await (async () => {'
|
||||||
const E2B_PYTHON_WRAPPER_LINES = 1
|
const E2B_PYTHON_WRAPPER_LINES = 1 // Lines before user code: 'def __sim_main__():'
|
||||||
|
|
||||||
type TypeScriptModule = typeof import('typescript')
|
type TypeScriptModule = typeof import('typescript')
|
||||||
|
|
||||||
@@ -135,21 +134,33 @@ function extractEnhancedError(
|
|||||||
if (error.stack) {
|
if (error.stack) {
|
||||||
enhanced.stack = error.stack
|
enhanced.stack = error.stack
|
||||||
|
|
||||||
|
// Parse stack trace to extract line and column information
|
||||||
|
// Handle both compilation errors and runtime errors
|
||||||
const stackLines: string[] = error.stack.split('\n')
|
const stackLines: string[] = error.stack.split('\n')
|
||||||
|
|
||||||
for (const line of stackLines) {
|
for (const line of stackLines) {
|
||||||
|
// Pattern 1: Compilation errors - "user-function.js:6"
|
||||||
let match = line.match(/user-function\.js:(\d+)(?::(\d+))?/)
|
let match = line.match(/user-function\.js:(\d+)(?::(\d+))?/)
|
||||||
|
|
||||||
|
// Pattern 2: Runtime errors - "at user-function.js:5:12"
|
||||||
if (!match) {
|
if (!match) {
|
||||||
match = line.match(/at\s+user-function\.js:(\d+):(\d+)/)
|
match = line.match(/at\s+user-function\.js:(\d+):(\d+)/)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pattern 3: Generic patterns for any line containing our filename
|
||||||
|
if (!match) {
|
||||||
|
match = line.match(/user-function\.js:(\d+)(?::(\d+))?/)
|
||||||
|
}
|
||||||
|
|
||||||
if (match) {
|
if (match) {
|
||||||
const stackLine = Number.parseInt(match[1], 10)
|
const stackLine = Number.parseInt(match[1], 10)
|
||||||
const stackColumn = match[2] ? Number.parseInt(match[2], 10) : undefined
|
const stackColumn = match[2] ? Number.parseInt(match[2], 10) : undefined
|
||||||
|
|
||||||
|
// Adjust line number to account for wrapper code
|
||||||
|
// The user code starts at a specific line in our wrapper
|
||||||
const adjustedLine = stackLine - userCodeStartLine + 1
|
const adjustedLine = stackLine - userCodeStartLine + 1
|
||||||
|
|
||||||
|
// Check if this is a syntax error in wrapper code caused by incomplete user code
|
||||||
const isWrapperSyntaxError =
|
const isWrapperSyntaxError =
|
||||||
stackLine > userCodeStartLine &&
|
stackLine > userCodeStartLine &&
|
||||||
error.name === 'SyntaxError' &&
|
error.name === 'SyntaxError' &&
|
||||||
@@ -157,6 +168,7 @@ function extractEnhancedError(
|
|||||||
error.message.includes('Unexpected end of input'))
|
error.message.includes('Unexpected end of input'))
|
||||||
|
|
||||||
if (isWrapperSyntaxError && userCode) {
|
if (isWrapperSyntaxError && userCode) {
|
||||||
|
// Map wrapper syntax errors to the last line of user code
|
||||||
const codeLines = userCode.split('\n')
|
const codeLines = userCode.split('\n')
|
||||||
const lastUserLine = codeLines.length
|
const lastUserLine = codeLines.length
|
||||||
enhanced.line = lastUserLine
|
enhanced.line = lastUserLine
|
||||||
@@ -169,6 +181,7 @@ function extractEnhancedError(
|
|||||||
enhanced.line = adjustedLine
|
enhanced.line = adjustedLine
|
||||||
enhanced.column = stackColumn
|
enhanced.column = stackColumn
|
||||||
|
|
||||||
|
// Extract the actual line content from user code
|
||||||
if (userCode) {
|
if (userCode) {
|
||||||
const codeLines = userCode.split('\n')
|
const codeLines = userCode.split('\n')
|
||||||
if (adjustedLine <= codeLines.length) {
|
if (adjustedLine <= codeLines.length) {
|
||||||
@@ -179,6 +192,7 @@ function extractEnhancedError(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (stackLine <= userCodeStartLine) {
|
if (stackLine <= userCodeStartLine) {
|
||||||
|
// Error is in wrapper code itself
|
||||||
enhanced.line = stackLine
|
enhanced.line = stackLine
|
||||||
enhanced.column = stackColumn
|
enhanced.column = stackColumn
|
||||||
break
|
break
|
||||||
@@ -186,6 +200,7 @@ function extractEnhancedError(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clean up stack trace to show user-relevant information
|
||||||
const cleanedStackLines: string[] = stackLines
|
const cleanedStackLines: string[] = stackLines
|
||||||
.filter(
|
.filter(
|
||||||
(line: string) =>
|
(line: string) =>
|
||||||
@@ -199,6 +214,9 @@ function extractEnhancedError(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keep original message without adding error type prefix
|
||||||
|
// The error type will be added later in createUserFriendlyErrorMessage
|
||||||
|
|
||||||
return enhanced
|
return enhanced
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,6 +231,7 @@ function formatE2BError(
|
|||||||
userCode: string,
|
userCode: string,
|
||||||
prologueLineCount: number
|
prologueLineCount: number
|
||||||
): { formattedError: string; cleanedOutput: string } {
|
): { formattedError: string; cleanedOutput: string } {
|
||||||
|
// Calculate line offset based on language and prologue
|
||||||
const wrapperLines =
|
const wrapperLines =
|
||||||
language === CodeLanguage.Python ? E2B_PYTHON_WRAPPER_LINES : E2B_JS_WRAPPER_LINES
|
language === CodeLanguage.Python ? E2B_PYTHON_WRAPPER_LINES : E2B_JS_WRAPPER_LINES
|
||||||
const totalOffset = prologueLineCount + wrapperLines
|
const totalOffset = prologueLineCount + wrapperLines
|
||||||
@@ -222,20 +241,27 @@ function formatE2BError(
|
|||||||
let cleanErrorMsg = ''
|
let cleanErrorMsg = ''
|
||||||
|
|
||||||
if (language === CodeLanguage.Python) {
|
if (language === CodeLanguage.Python) {
|
||||||
|
// Python error format: "Cell In[X], line Y" followed by error details
|
||||||
|
// Extract line number from the Cell reference
|
||||||
const cellMatch = errorOutput.match(/Cell In\[\d+\], line (\d+)/)
|
const cellMatch = errorOutput.match(/Cell In\[\d+\], line (\d+)/)
|
||||||
if (cellMatch) {
|
if (cellMatch) {
|
||||||
const originalLine = Number.parseInt(cellMatch[1], 10)
|
const originalLine = Number.parseInt(cellMatch[1], 10)
|
||||||
userLine = originalLine - totalOffset
|
userLine = originalLine - totalOffset
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract clean error message from the error string
|
||||||
|
// Remove file references like "(detected at line X) (file.py, line Y)"
|
||||||
cleanErrorMsg = errorMessage
|
cleanErrorMsg = errorMessage
|
||||||
.replace(/\s*\(detected at line \d+\)/g, '')
|
.replace(/\s*\(detected at line \d+\)/g, '')
|
||||||
.replace(/\s*\([^)]+\.py, line \d+\)/g, '')
|
.replace(/\s*\([^)]+\.py, line \d+\)/g, '')
|
||||||
.trim()
|
.trim()
|
||||||
} else if (language === CodeLanguage.JavaScript) {
|
} else if (language === CodeLanguage.JavaScript) {
|
||||||
|
// JavaScript error format from E2B: "SyntaxError: /path/file.ts: Message. (line:col)\n\n 9 | ..."
|
||||||
|
// First, extract the error type and message from the first line
|
||||||
const firstLineEnd = errorMessage.indexOf('\n')
|
const firstLineEnd = errorMessage.indexOf('\n')
|
||||||
const firstLine = firstLineEnd > 0 ? errorMessage.substring(0, firstLineEnd) : errorMessage
|
const firstLine = firstLineEnd > 0 ? errorMessage.substring(0, firstLineEnd) : errorMessage
|
||||||
|
|
||||||
|
// Parse: "SyntaxError: /home/user/index.ts: Missing semicolon. (11:9)"
|
||||||
const jsErrorMatch = firstLine.match(/^(\w+Error):\s*[^:]+:\s*([^(]+)\.\s*\((\d+):(\d+)\)/)
|
const jsErrorMatch = firstLine.match(/^(\w+Error):\s*[^:]+:\s*([^(]+)\.\s*\((\d+):(\d+)\)/)
|
||||||
if (jsErrorMatch) {
|
if (jsErrorMatch) {
|
||||||
cleanErrorType = jsErrorMatch[1]
|
cleanErrorType = jsErrorMatch[1]
|
||||||
@@ -243,11 +269,13 @@ function formatE2BError(
|
|||||||
const originalLine = Number.parseInt(jsErrorMatch[3], 10)
|
const originalLine = Number.parseInt(jsErrorMatch[3], 10)
|
||||||
userLine = originalLine - totalOffset
|
userLine = originalLine - totalOffset
|
||||||
} else {
|
} else {
|
||||||
|
// Fallback: look for line number in the arrow pointer line (> 11 |)
|
||||||
const arrowMatch = errorMessage.match(/^>\s*(\d+)\s*\|/m)
|
const arrowMatch = errorMessage.match(/^>\s*(\d+)\s*\|/m)
|
||||||
if (arrowMatch) {
|
if (arrowMatch) {
|
||||||
const originalLine = Number.parseInt(arrowMatch[1], 10)
|
const originalLine = Number.parseInt(arrowMatch[1], 10)
|
||||||
userLine = originalLine - totalOffset
|
userLine = originalLine - totalOffset
|
||||||
}
|
}
|
||||||
|
// Try to extract error type and message
|
||||||
const errorMatch = firstLine.match(/^(\w+Error):\s*(.+)/)
|
const errorMatch = firstLine.match(/^(\w+Error):\s*(.+)/)
|
||||||
if (errorMatch) {
|
if (errorMatch) {
|
||||||
cleanErrorType = errorMatch[1]
|
cleanErrorType = errorMatch[1]
|
||||||
@@ -261,11 +289,13 @@ function formatE2BError(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build the final clean error message
|
||||||
const finalErrorMsg =
|
const finalErrorMsg =
|
||||||
cleanErrorType && cleanErrorMsg
|
cleanErrorType && cleanErrorMsg
|
||||||
? `${cleanErrorType}: ${cleanErrorMsg}`
|
? `${cleanErrorType}: ${cleanErrorMsg}`
|
||||||
: cleanErrorMsg || errorMessage
|
: cleanErrorMsg || errorMessage
|
||||||
|
|
||||||
|
// Format with line number if available
|
||||||
let formattedError = finalErrorMsg
|
let formattedError = finalErrorMsg
|
||||||
if (userLine && userLine > 0) {
|
if (userLine && userLine > 0) {
|
||||||
const codeLines = userCode.split('\n')
|
const codeLines = userCode.split('\n')
|
||||||
@@ -281,6 +311,7 @@ function formatE2BError(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For stdout, just return the clean error message without the full traceback
|
||||||
const cleanedOutput = finalErrorMsg
|
const cleanedOutput = finalErrorMsg
|
||||||
|
|
||||||
return { formattedError, cleanedOutput }
|
return { formattedError, cleanedOutput }
|
||||||
@@ -296,6 +327,7 @@ function createUserFriendlyErrorMessage(
|
|||||||
): string {
|
): string {
|
||||||
let errorMessage = enhanced.message
|
let errorMessage = enhanced.message
|
||||||
|
|
||||||
|
// Add line information if available
|
||||||
if (enhanced.line !== undefined) {
|
if (enhanced.line !== undefined) {
|
||||||
let lineInfo = `Line ${enhanced.line}`
|
let lineInfo = `Line ${enhanced.line}`
|
||||||
|
|
||||||
@@ -306,14 +338,18 @@ function createUserFriendlyErrorMessage(
|
|||||||
|
|
||||||
errorMessage = `${lineInfo} - ${errorMessage}`
|
errorMessage = `${lineInfo} - ${errorMessage}`
|
||||||
} else {
|
} else {
|
||||||
|
// If no line number, try to extract it from stack trace for display
|
||||||
if (enhanced.stack) {
|
if (enhanced.stack) {
|
||||||
const stackMatch = enhanced.stack.match(/user-function\.js:(\d+)(?::(\d+))?/)
|
const stackMatch = enhanced.stack.match(/user-function\.js:(\d+)(?::(\d+))?/)
|
||||||
if (stackMatch) {
|
if (stackMatch) {
|
||||||
const line = Number.parseInt(stackMatch[1], 10)
|
const line = Number.parseInt(stackMatch[1], 10)
|
||||||
let lineInfo = `Line ${line}`
|
let lineInfo = `Line ${line}`
|
||||||
|
|
||||||
|
// Try to get line content if we have userCode
|
||||||
if (userCode) {
|
if (userCode) {
|
||||||
const codeLines = userCode.split('\n')
|
const codeLines = userCode.split('\n')
|
||||||
|
// Note: stackMatch gives us VM line number, need to adjust
|
||||||
|
// This is a fallback case, so we might not have perfect line mapping
|
||||||
if (line <= codeLines.length) {
|
if (line <= codeLines.length) {
|
||||||
const lineContent = codeLines[line - 1]?.trim()
|
const lineContent = codeLines[line - 1]?.trim()
|
||||||
if (lineContent) {
|
if (lineContent) {
|
||||||
@@ -327,6 +363,7 @@ function createUserFriendlyErrorMessage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add error type prefix with consistent naming
|
||||||
if (enhanced.name !== 'Error') {
|
if (enhanced.name !== 'Error') {
|
||||||
const errorTypePrefix =
|
const errorTypePrefix =
|
||||||
enhanced.name === 'SyntaxError'
|
enhanced.name === 'SyntaxError'
|
||||||
@@ -337,6 +374,7 @@ function createUserFriendlyErrorMessage(
|
|||||||
? 'Reference Error'
|
? 'Reference Error'
|
||||||
: enhanced.name
|
: enhanced.name
|
||||||
|
|
||||||
|
// Only add prefix if not already present
|
||||||
if (!errorMessage.toLowerCase().includes(errorTypePrefix.toLowerCase())) {
|
if (!errorMessage.toLowerCase().includes(errorTypePrefix.toLowerCase())) {
|
||||||
errorMessage = `${errorTypePrefix}: ${errorMessage}`
|
errorMessage = `${errorTypePrefix}: ${errorMessage}`
|
||||||
}
|
}
|
||||||
@@ -345,6 +383,9 @@ function createUserFriendlyErrorMessage(
|
|||||||
return errorMessage
|
return errorMessage
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves workflow variables with <variable.name> syntax
|
||||||
|
*/
|
||||||
function resolveWorkflowVariables(
|
function resolveWorkflowVariables(
|
||||||
code: string,
|
code: string,
|
||||||
workflowVariables: Record<string, any>,
|
workflowVariables: Record<string, any>,
|
||||||
@@ -364,35 +405,39 @@ function resolveWorkflowVariables(
|
|||||||
while ((match = regex.exec(code)) !== null) {
|
while ((match = regex.exec(code)) !== null) {
|
||||||
const variableName = match[1].trim()
|
const variableName = match[1].trim()
|
||||||
|
|
||||||
|
// Find the variable by name (workflowVariables is indexed by ID, values are variable objects)
|
||||||
const foundVariable = Object.entries(workflowVariables).find(
|
const foundVariable = Object.entries(workflowVariables).find(
|
||||||
([_, variable]) => normalizeName(variable.name || '') === variableName
|
([_, variable]) => normalizeName(variable.name || '') === variableName
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!foundVariable) {
|
let variableValue: unknown = ''
|
||||||
const availableVars = Object.values(workflowVariables)
|
if (foundVariable) {
|
||||||
.map((v) => v.name)
|
|
||||||
.filter(Boolean)
|
|
||||||
throw new Error(
|
|
||||||
`Variable "${variableName}" doesn't exist.` +
|
|
||||||
(availableVars.length > 0 ? ` Available: ${availableVars.join(', ')}` : '')
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const variable = foundVariable[1]
|
const variable = foundVariable[1]
|
||||||
let variableValue: unknown = variable.value
|
variableValue = variable.value
|
||||||
|
|
||||||
if (variable.value !== undefined && variable.value !== null) {
|
if (variable.value !== undefined && variable.value !== null) {
|
||||||
|
try {
|
||||||
|
// Handle 'string' type the same as 'plain' for backward compatibility
|
||||||
const type = variable.type === 'string' ? 'plain' : variable.type
|
const type = variable.type === 'string' ? 'plain' : variable.type
|
||||||
|
|
||||||
if (type === 'number') {
|
// For plain text, use exactly what's entered without modifications
|
||||||
|
if (type === 'plain' && typeof variableValue === 'string') {
|
||||||
|
// Use as-is for plain text
|
||||||
|
} else if (type === 'number') {
|
||||||
variableValue = Number(variableValue)
|
variableValue = Number(variableValue)
|
||||||
} else if (type === 'boolean') {
|
} else if (type === 'boolean') {
|
||||||
variableValue = variableValue === 'true' || variableValue === true
|
variableValue = variableValue === 'true' || variableValue === true
|
||||||
} else if (type === 'json' && typeof variableValue === 'string') {
|
} else if (type === 'json') {
|
||||||
try {
|
try {
|
||||||
variableValue = JSON.parse(variableValue)
|
variableValue =
|
||||||
|
typeof variableValue === 'string' ? JSON.parse(variableValue) : variableValue
|
||||||
} catch {
|
} catch {
|
||||||
// Keep as-is
|
// Keep original value if JSON parsing fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fallback to original value on error
|
||||||
|
variableValue = variable.value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -405,9 +450,11 @@ function resolveWorkflowVariables(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Process replacements in reverse order to maintain correct indices
|
||||||
for (let i = replacements.length - 1; i >= 0; i--) {
|
for (let i = replacements.length - 1; i >= 0; i--) {
|
||||||
const { match: matchStr, index, variableName, variableValue } = replacements[i]
|
const { match: matchStr, index, variableName, variableValue } = replacements[i]
|
||||||
|
|
||||||
|
// Use variable reference approach
|
||||||
const safeVarName = `__variable_${variableName.replace(/[^a-zA-Z0-9_]/g, '_')}`
|
const safeVarName = `__variable_${variableName.replace(/[^a-zA-Z0-9_]/g, '_')}`
|
||||||
contextVariables[safeVarName] = variableValue
|
contextVariables[safeVarName] = variableValue
|
||||||
resolvedCode =
|
resolvedCode =
|
||||||
@@ -417,6 +464,9 @@ function resolveWorkflowVariables(
|
|||||||
return resolvedCode
|
return resolvedCode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves environment variables with {{var_name}} syntax
|
||||||
|
*/
|
||||||
function resolveEnvironmentVariables(
|
function resolveEnvironmentVariables(
|
||||||
code: string,
|
code: string,
|
||||||
params: Record<string, any>,
|
params: Record<string, any>,
|
||||||
@@ -432,28 +482,32 @@ function resolveEnvironmentVariables(
|
|||||||
|
|
||||||
const resolverVars: Record<string, string> = {}
|
const resolverVars: Record<string, string> = {}
|
||||||
Object.entries(params).forEach(([key, value]) => {
|
Object.entries(params).forEach(([key, value]) => {
|
||||||
if (value !== undefined && value !== null) {
|
if (value) {
|
||||||
resolverVars[key] = String(value)
|
resolverVars[key] = String(value)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
Object.entries(envVars).forEach(([key, value]) => {
|
Object.entries(envVars).forEach(([key, value]) => {
|
||||||
if (value !== undefined && value !== null) {
|
if (value) {
|
||||||
resolverVars[key] = value
|
resolverVars[key] = value
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
while ((match = regex.exec(code)) !== null) {
|
while ((match = regex.exec(code)) !== null) {
|
||||||
const varName = match[1].trim()
|
const varName = match[1].trim()
|
||||||
|
const resolved = resolveEnvVarReferences(match[0], resolverVars, {
|
||||||
if (!(varName in resolverVars)) {
|
allowEmbedded: true,
|
||||||
continue
|
resolveExactMatch: true,
|
||||||
}
|
trimKeys: true,
|
||||||
|
onMissing: 'empty',
|
||||||
|
deep: false,
|
||||||
|
})
|
||||||
|
const varValue =
|
||||||
|
typeof resolved === 'string' ? resolved : resolved == null ? '' : String(resolved)
|
||||||
replacements.push({
|
replacements.push({
|
||||||
match: match[0],
|
match: match[0],
|
||||||
index: match.index,
|
index: match.index,
|
||||||
varName,
|
varName,
|
||||||
varValue: resolverVars[varName],
|
varValue: String(varValue),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -469,59 +523,64 @@ function resolveEnvironmentVariables(
|
|||||||
return resolvedCode
|
return resolvedCode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves tags with <tag_name> syntax (including nested paths like <block.response.data>)
|
||||||
|
*/
|
||||||
function resolveTagVariables(
|
function resolveTagVariables(
|
||||||
code: string,
|
code: string,
|
||||||
blockData: Record<string, unknown>,
|
params: Record<string, any>,
|
||||||
|
blockData: Record<string, any>,
|
||||||
blockNameMapping: Record<string, string>,
|
blockNameMapping: Record<string, string>,
|
||||||
blockOutputSchemas: Record<string, OutputSchema>,
|
contextVariables: Record<string, any>
|
||||||
contextVariables: Record<string, unknown>,
|
|
||||||
language = 'javascript'
|
|
||||||
): string {
|
): string {
|
||||||
let resolvedCode = code
|
let resolvedCode = code
|
||||||
const undefinedLiteral = language === 'python' ? 'None' : 'undefined'
|
|
||||||
|
|
||||||
const tagPattern = new RegExp(
|
const tagPattern = new RegExp(
|
||||||
`${REFERENCE.START}([a-zA-Z_](?:[a-zA-Z0-9_${REFERENCE.PATH_DELIMITER}]*[a-zA-Z0-9_])?)${REFERENCE.END}`,
|
`${REFERENCE.START}([a-zA-Z_][a-zA-Z0-9_${REFERENCE.PATH_DELIMITER}]*[a-zA-Z0-9_])${REFERENCE.END}`,
|
||||||
'g'
|
'g'
|
||||||
)
|
)
|
||||||
const tagMatches = resolvedCode.match(tagPattern) || []
|
const tagMatches = resolvedCode.match(tagPattern) || []
|
||||||
|
|
||||||
for (const match of tagMatches) {
|
for (const match of tagMatches) {
|
||||||
const tagName = match.slice(REFERENCE.START.length, -REFERENCE.END.length).trim()
|
const tagName = match.slice(REFERENCE.START.length, -REFERENCE.END.length).trim()
|
||||||
|
|
||||||
|
// Handle nested paths like "getrecord.response.data" or "function1.response.result"
|
||||||
|
// First try params, then blockData directly, then try with block name mapping
|
||||||
|
let tagValue = getNestedValue(params, tagName) || getNestedValue(blockData, tagName) || ''
|
||||||
|
|
||||||
|
// If not found and the path starts with a block name, try mapping the block name to ID
|
||||||
|
if (!tagValue && tagName.includes(REFERENCE.PATH_DELIMITER)) {
|
||||||
const pathParts = tagName.split(REFERENCE.PATH_DELIMITER)
|
const pathParts = tagName.split(REFERENCE.PATH_DELIMITER)
|
||||||
const blockName = pathParts[0]
|
const normalizedBlockName = pathParts[0] // This should already be normalized like "function1"
|
||||||
const fieldPath = pathParts.slice(1)
|
|
||||||
|
|
||||||
const result = resolveBlockReference(blockName, fieldPath, {
|
// Direct lookup using normalized block name
|
||||||
blockNameMapping,
|
const blockId = blockNameMapping[normalizedBlockName] ?? null
|
||||||
blockData,
|
|
||||||
blockOutputSchemas,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!result) {
|
if (blockId) {
|
||||||
continue
|
const remainingPath = pathParts.slice(1).join('.')
|
||||||
|
const fullPath = `${blockId}.${remainingPath}`
|
||||||
|
tagValue = getNestedValue(blockData, fullPath) || ''
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let tagValue = result.value
|
// If the value is a stringified JSON, parse it back to object
|
||||||
|
if (
|
||||||
if (tagValue === undefined) {
|
typeof tagValue === 'string' &&
|
||||||
resolvedCode = resolvedCode.replace(new RegExp(escapeRegExp(match), 'g'), undefinedLiteral)
|
tagValue.length > 100 &&
|
||||||
continue
|
(tagValue.startsWith('{') || tagValue.startsWith('['))
|
||||||
}
|
) {
|
||||||
|
|
||||||
if (typeof tagValue === 'string') {
|
|
||||||
const trimmed = tagValue.trimStart()
|
|
||||||
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
|
||||||
try {
|
try {
|
||||||
tagValue = JSON.parse(tagValue)
|
tagValue = JSON.parse(tagValue)
|
||||||
} catch {
|
} catch (e) {
|
||||||
// Keep as string if not valid JSON
|
// Keep as string if parsing fails
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const safeVarName = `__tag_${tagName.replace(/_/g, '_1').replace(/\./g, '_0')}`
|
// Instead of injecting large JSON directly, create a variable reference
|
||||||
|
const safeVarName = `__tag_${tagName.replace(/[^a-zA-Z0-9_]/g, '_')}`
|
||||||
contextVariables[safeVarName] = tagValue
|
contextVariables[safeVarName] = tagValue
|
||||||
|
|
||||||
|
// Replace the template with a variable reference
|
||||||
resolvedCode = resolvedCode.replace(new RegExp(escapeRegExp(match), 'g'), safeVarName)
|
resolvedCode = resolvedCode.replace(new RegExp(escapeRegExp(match), 'g'), safeVarName)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -537,31 +596,44 @@ function resolveTagVariables(
|
|||||||
*/
|
*/
|
||||||
function resolveCodeVariables(
|
function resolveCodeVariables(
|
||||||
code: string,
|
code: string,
|
||||||
params: Record<string, unknown>,
|
params: Record<string, any>,
|
||||||
envVars: Record<string, string> = {},
|
envVars: Record<string, string> = {},
|
||||||
blockData: Record<string, unknown> = {},
|
blockData: Record<string, any> = {},
|
||||||
blockNameMapping: Record<string, string> = {},
|
blockNameMapping: Record<string, string> = {},
|
||||||
blockOutputSchemas: Record<string, OutputSchema> = {},
|
workflowVariables: Record<string, any> = {}
|
||||||
workflowVariables: Record<string, unknown> = {},
|
): { resolvedCode: string; contextVariables: Record<string, any> } {
|
||||||
language = 'javascript'
|
|
||||||
): { resolvedCode: string; contextVariables: Record<string, unknown> } {
|
|
||||||
let resolvedCode = code
|
let resolvedCode = code
|
||||||
const contextVariables: Record<string, unknown> = {}
|
const contextVariables: Record<string, any> = {}
|
||||||
|
|
||||||
|
// Resolve workflow variables with <variable.name> syntax first
|
||||||
resolvedCode = resolveWorkflowVariables(resolvedCode, workflowVariables, contextVariables)
|
resolvedCode = resolveWorkflowVariables(resolvedCode, workflowVariables, contextVariables)
|
||||||
|
|
||||||
|
// Resolve environment variables with {{var_name}} syntax
|
||||||
resolvedCode = resolveEnvironmentVariables(resolvedCode, params, envVars, contextVariables)
|
resolvedCode = resolveEnvironmentVariables(resolvedCode, params, envVars, contextVariables)
|
||||||
|
|
||||||
|
// Resolve tags with <tag_name> syntax (including nested paths like <block.response.data>)
|
||||||
resolvedCode = resolveTagVariables(
|
resolvedCode = resolveTagVariables(
|
||||||
resolvedCode,
|
resolvedCode,
|
||||||
|
params,
|
||||||
blockData,
|
blockData,
|
||||||
blockNameMapping,
|
blockNameMapping,
|
||||||
blockOutputSchemas,
|
contextVariables
|
||||||
contextVariables,
|
|
||||||
language
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return { resolvedCode, contextVariables }
|
return { resolvedCode, contextVariables }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get nested value from object using dot notation path
|
||||||
|
*/
|
||||||
|
function getNestedValue(obj: any, path: string): any {
|
||||||
|
if (!obj || !path) return undefined
|
||||||
|
|
||||||
|
return path.split('.').reduce((current, key) => {
|
||||||
|
return current && typeof current === 'object' ? current[key] : undefined
|
||||||
|
}, obj)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove one trailing newline from stdout
|
* Remove one trailing newline from stdout
|
||||||
* This handles the common case where print() or console.log() adds a trailing \n
|
* This handles the common case where print() or console.log() adds a trailing \n
|
||||||
@@ -582,12 +654,6 @@ export async function POST(req: NextRequest) {
|
|||||||
let resolvedCode = '' // Store resolved code for error reporting
|
let resolvedCode = '' // Store resolved code for error reporting
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const auth = await checkInternalAuth(req)
|
|
||||||
if (!auth.success || !auth.userId) {
|
|
||||||
logger.warn(`[${requestId}] Unauthorized function execution attempt`)
|
|
||||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await req.json()
|
const body = await req.json()
|
||||||
|
|
||||||
const { DEFAULT_EXECUTION_TIMEOUT_MS } = await import('@/lib/execution/constants')
|
const { DEFAULT_EXECUTION_TIMEOUT_MS } = await import('@/lib/execution/constants')
|
||||||
@@ -600,12 +666,12 @@ export async function POST(req: NextRequest) {
|
|||||||
envVars = {},
|
envVars = {},
|
||||||
blockData = {},
|
blockData = {},
|
||||||
blockNameMapping = {},
|
blockNameMapping = {},
|
||||||
blockOutputSchemas = {},
|
|
||||||
workflowVariables = {},
|
workflowVariables = {},
|
||||||
workflowId,
|
workflowId,
|
||||||
isCustomTool = false,
|
isCustomTool = false,
|
||||||
} = body
|
} = body
|
||||||
|
|
||||||
|
// Extract internal parameters that shouldn't be passed to the execution context
|
||||||
const executionParams = { ...params }
|
const executionParams = { ...params }
|
||||||
executionParams._context = undefined
|
executionParams._context = undefined
|
||||||
|
|
||||||
@@ -617,21 +683,21 @@ export async function POST(req: NextRequest) {
|
|||||||
isCustomTool,
|
isCustomTool,
|
||||||
})
|
})
|
||||||
|
|
||||||
const lang = isValidCodeLanguage(language) ? language : DEFAULT_CODE_LANGUAGE
|
// Resolve variables in the code with workflow environment variables
|
||||||
|
|
||||||
const codeResolution = resolveCodeVariables(
|
const codeResolution = resolveCodeVariables(
|
||||||
code,
|
code,
|
||||||
executionParams,
|
executionParams,
|
||||||
envVars,
|
envVars,
|
||||||
blockData,
|
blockData,
|
||||||
blockNameMapping,
|
blockNameMapping,
|
||||||
blockOutputSchemas,
|
workflowVariables
|
||||||
workflowVariables,
|
|
||||||
lang
|
|
||||||
)
|
)
|
||||||
resolvedCode = codeResolution.resolvedCode
|
resolvedCode = codeResolution.resolvedCode
|
||||||
const contextVariables = codeResolution.contextVariables
|
const contextVariables = codeResolution.contextVariables
|
||||||
|
|
||||||
|
const lang = isValidCodeLanguage(language) ? language : DEFAULT_CODE_LANGUAGE
|
||||||
|
|
||||||
|
// Extract imports once for JavaScript code (reuse later to avoid double extraction)
|
||||||
let jsImports = ''
|
let jsImports = ''
|
||||||
let jsRemainingCode = resolvedCode
|
let jsRemainingCode = resolvedCode
|
||||||
let hasImports = false
|
let hasImports = false
|
||||||
@@ -641,22 +707,31 @@ export async function POST(req: NextRequest) {
|
|||||||
jsImports = extractionResult.imports
|
jsImports = extractionResult.imports
|
||||||
jsRemainingCode = extractionResult.remainingCode
|
jsRemainingCode = extractionResult.remainingCode
|
||||||
|
|
||||||
|
// Check for ES6 imports or CommonJS require statements
|
||||||
|
// ES6 imports are extracted by the TypeScript parser
|
||||||
|
// Also check for require() calls which indicate external dependencies
|
||||||
const hasRequireStatements = /require\s*\(\s*['"`]/.test(resolvedCode)
|
const hasRequireStatements = /require\s*\(\s*['"`]/.test(resolvedCode)
|
||||||
hasImports = jsImports.trim().length > 0 || hasRequireStatements
|
hasImports = jsImports.trim().length > 0 || hasRequireStatements
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Python always requires E2B
|
||||||
if (lang === CodeLanguage.Python && !isE2bEnabled) {
|
if (lang === CodeLanguage.Python && !isE2bEnabled) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Python execution requires E2B to be enabled. Please contact your administrator to enable E2B, or use JavaScript instead.'
|
'Python execution requires E2B to be enabled. Please contact your administrator to enable E2B, or use JavaScript instead.'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// JavaScript with imports requires E2B
|
||||||
if (lang === CodeLanguage.JavaScript && hasImports && !isE2bEnabled) {
|
if (lang === CodeLanguage.JavaScript && hasImports && !isE2bEnabled) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'JavaScript code with import statements requires E2B to be enabled. Please remove the import statements, or contact your administrator to enable E2B.'
|
'JavaScript code with import statements requires E2B to be enabled. Please remove the import statements, or contact your administrator to enable E2B.'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use E2B if:
|
||||||
|
// - E2B is enabled AND
|
||||||
|
// - Not a custom tool AND
|
||||||
|
// - (Python OR JavaScript with imports)
|
||||||
const useE2B =
|
const useE2B =
|
||||||
isE2bEnabled &&
|
isE2bEnabled &&
|
||||||
!isCustomTool &&
|
!isCustomTool &&
|
||||||
@@ -669,10 +744,13 @@ export async function POST(req: NextRequest) {
|
|||||||
language: lang,
|
language: lang,
|
||||||
})
|
})
|
||||||
let prologue = ''
|
let prologue = ''
|
||||||
|
const epilogue = ''
|
||||||
|
|
||||||
if (lang === CodeLanguage.JavaScript) {
|
if (lang === CodeLanguage.JavaScript) {
|
||||||
|
// Track prologue lines for error adjustment
|
||||||
let prologueLineCount = 0
|
let prologueLineCount = 0
|
||||||
|
|
||||||
|
// Reuse the imports we already extracted earlier
|
||||||
const imports = jsImports
|
const imports = jsImports
|
||||||
const remainingCode = jsRemainingCode
|
const remainingCode = jsRemainingCode
|
||||||
|
|
||||||
@@ -687,11 +765,7 @@ export async function POST(req: NextRequest) {
|
|||||||
prologue += `const environmentVariables = JSON.parse(${JSON.stringify(JSON.stringify(envVars))});\n`
|
prologue += `const environmentVariables = JSON.parse(${JSON.stringify(JSON.stringify(envVars))});\n`
|
||||||
prologueLineCount++
|
prologueLineCount++
|
||||||
for (const [k, v] of Object.entries(contextVariables)) {
|
for (const [k, v] of Object.entries(contextVariables)) {
|
||||||
if (v === undefined) {
|
|
||||||
prologue += `const ${k} = undefined;\n`
|
|
||||||
} else {
|
|
||||||
prologue += `const ${k} = JSON.parse(${JSON.stringify(JSON.stringify(v))});\n`
|
prologue += `const ${k} = JSON.parse(${JSON.stringify(JSON.stringify(v))});\n`
|
||||||
}
|
|
||||||
prologueLineCount++
|
prologueLineCount++
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -708,7 +782,7 @@ export async function POST(req: NextRequest) {
|
|||||||
' }',
|
' }',
|
||||||
'})();',
|
'})();',
|
||||||
].join('\n')
|
].join('\n')
|
||||||
const codeForE2B = importSection + prologue + wrapped
|
const codeForE2B = importSection + prologue + wrapped + epilogue
|
||||||
|
|
||||||
const execStart = Date.now()
|
const execStart = Date.now()
|
||||||
const {
|
const {
|
||||||
@@ -730,6 +804,7 @@ export async function POST(req: NextRequest) {
|
|||||||
error: e2bError,
|
error: e2bError,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// If there was an execution error, format it properly
|
||||||
if (e2bError) {
|
if (e2bError) {
|
||||||
const { formattedError, cleanedOutput } = formatE2BError(
|
const { formattedError, cleanedOutput } = formatE2BError(
|
||||||
e2bError,
|
e2bError,
|
||||||
@@ -753,7 +828,7 @@ export async function POST(req: NextRequest) {
|
|||||||
output: { result: e2bResult ?? null, stdout: cleanStdout(stdout), executionTime },
|
output: { result: e2bResult ?? null, stdout: cleanStdout(stdout), executionTime },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
// Track prologue lines for error adjustment
|
||||||
let prologueLineCount = 0
|
let prologueLineCount = 0
|
||||||
prologue += 'import json\n'
|
prologue += 'import json\n'
|
||||||
prologueLineCount++
|
prologueLineCount++
|
||||||
@@ -762,11 +837,7 @@ export async function POST(req: NextRequest) {
|
|||||||
prologue += `environmentVariables = json.loads(${JSON.stringify(JSON.stringify(envVars))})\n`
|
prologue += `environmentVariables = json.loads(${JSON.stringify(JSON.stringify(envVars))})\n`
|
||||||
prologueLineCount++
|
prologueLineCount++
|
||||||
for (const [k, v] of Object.entries(contextVariables)) {
|
for (const [k, v] of Object.entries(contextVariables)) {
|
||||||
if (v === undefined) {
|
|
||||||
prologue += `${k} = None\n`
|
|
||||||
} else {
|
|
||||||
prologue += `${k} = json.loads(${JSON.stringify(JSON.stringify(v))})\n`
|
prologue += `${k} = json.loads(${JSON.stringify(JSON.stringify(v))})\n`
|
||||||
}
|
|
||||||
prologueLineCount++
|
prologueLineCount++
|
||||||
}
|
}
|
||||||
const wrapped = [
|
const wrapped = [
|
||||||
@@ -775,7 +846,7 @@ export async function POST(req: NextRequest) {
|
|||||||
'__sim_result__ = __sim_main__()',
|
'__sim_result__ = __sim_main__()',
|
||||||
"print('__SIM_RESULT__=' + json.dumps(__sim_result__))",
|
"print('__SIM_RESULT__=' + json.dumps(__sim_result__))",
|
||||||
].join('\n')
|
].join('\n')
|
||||||
const codeForE2B = prologue + wrapped
|
const codeForE2B = prologue + wrapped + epilogue
|
||||||
|
|
||||||
const execStart = Date.now()
|
const execStart = Date.now()
|
||||||
const {
|
const {
|
||||||
@@ -797,6 +868,7 @@ export async function POST(req: NextRequest) {
|
|||||||
error: e2bError,
|
error: e2bError,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// If there was an execution error, format it properly
|
||||||
if (e2bError) {
|
if (e2bError) {
|
||||||
const { formattedError, cleanedOutput } = formatE2BError(
|
const { formattedError, cleanedOutput } = formatE2BError(
|
||||||
e2bError,
|
e2bError,
|
||||||
@@ -825,6 +897,7 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
const wrapperLines = ['(async () => {', ' try {']
|
const wrapperLines = ['(async () => {', ' try {']
|
||||||
if (isCustomTool) {
|
if (isCustomTool) {
|
||||||
|
wrapperLines.push(' // For custom tools, make parameters directly accessible')
|
||||||
Object.keys(executionParams).forEach((key) => {
|
Object.keys(executionParams).forEach((key) => {
|
||||||
wrapperLines.push(` const ${key} = params.${key};`)
|
wrapperLines.push(` const ${key} = params.${key};`)
|
||||||
})
|
})
|
||||||
@@ -858,10 +931,12 @@ export async function POST(req: NextRequest) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const ivmError = isolatedResult.error
|
const ivmError = isolatedResult.error
|
||||||
|
// Adjust line number for prepended param destructuring in custom tools
|
||||||
let adjustedLine = ivmError.line
|
let adjustedLine = ivmError.line
|
||||||
let adjustedLineContent = ivmError.lineContent
|
let adjustedLineContent = ivmError.lineContent
|
||||||
if (prependedLineCount > 0 && ivmError.line !== undefined) {
|
if (prependedLineCount > 0 && ivmError.line !== undefined) {
|
||||||
adjustedLine = Math.max(1, ivmError.line - prependedLineCount)
|
adjustedLine = Math.max(1, ivmError.line - prependedLineCount)
|
||||||
|
// Get line content from original user code, not the prepended code
|
||||||
const codeLines = resolvedCode.split('\n')
|
const codeLines = resolvedCode.split('\n')
|
||||||
if (adjustedLine <= codeLines.length) {
|
if (adjustedLine <= codeLines.length) {
|
||||||
adjustedLineContent = codeLines[adjustedLine - 1]?.trim()
|
adjustedLineContent = codeLines[adjustedLine - 1]?.trim()
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ describe('Knowledge Base Documents API Route', () => {
|
|||||||
expect(vi.mocked(getDocuments)).toHaveBeenCalledWith(
|
expect(vi.mocked(getDocuments)).toHaveBeenCalledWith(
|
||||||
'kb-123',
|
'kb-123',
|
||||||
{
|
{
|
||||||
enabledFilter: undefined,
|
includeDisabled: false,
|
||||||
search: undefined,
|
search: undefined,
|
||||||
limit: 50,
|
limit: 50,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
@@ -166,7 +166,7 @@ describe('Knowledge Base Documents API Route', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should return documents with default filter', async () => {
|
it('should filter disabled documents by default', async () => {
|
||||||
const { checkKnowledgeBaseAccess } = await import('@/app/api/knowledge/utils')
|
const { checkKnowledgeBaseAccess } = await import('@/app/api/knowledge/utils')
|
||||||
const { getDocuments } = await import('@/lib/knowledge/documents/service')
|
const { getDocuments } = await import('@/lib/knowledge/documents/service')
|
||||||
|
|
||||||
@@ -194,7 +194,7 @@ describe('Knowledge Base Documents API Route', () => {
|
|||||||
expect(vi.mocked(getDocuments)).toHaveBeenCalledWith(
|
expect(vi.mocked(getDocuments)).toHaveBeenCalledWith(
|
||||||
'kb-123',
|
'kb-123',
|
||||||
{
|
{
|
||||||
enabledFilter: undefined,
|
includeDisabled: false,
|
||||||
search: undefined,
|
search: undefined,
|
||||||
limit: 50,
|
limit: 50,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
@@ -203,7 +203,7 @@ describe('Knowledge Base Documents API Route', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should filter documents by enabled status when requested', async () => {
|
it('should include disabled documents when requested', async () => {
|
||||||
const { checkKnowledgeBaseAccess } = await import('@/app/api/knowledge/utils')
|
const { checkKnowledgeBaseAccess } = await import('@/app/api/knowledge/utils')
|
||||||
const { getDocuments } = await import('@/lib/knowledge/documents/service')
|
const { getDocuments } = await import('@/lib/knowledge/documents/service')
|
||||||
|
|
||||||
@@ -223,7 +223,7 @@ describe('Knowledge Base Documents API Route', () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const url = 'http://localhost:3000/api/knowledge/kb-123/documents?enabledFilter=disabled'
|
const url = 'http://localhost:3000/api/knowledge/kb-123/documents?includeDisabled=true'
|
||||||
const req = new Request(url, { method: 'GET' }) as any
|
const req = new Request(url, { method: 'GET' }) as any
|
||||||
|
|
||||||
const { GET } = await import('@/app/api/knowledge/[id]/documents/route')
|
const { GET } = await import('@/app/api/knowledge/[id]/documents/route')
|
||||||
@@ -233,7 +233,7 @@ describe('Knowledge Base Documents API Route', () => {
|
|||||||
expect(vi.mocked(getDocuments)).toHaveBeenCalledWith(
|
expect(vi.mocked(getDocuments)).toHaveBeenCalledWith(
|
||||||
'kb-123',
|
'kb-123',
|
||||||
{
|
{
|
||||||
enabledFilter: 'disabled',
|
includeDisabled: true,
|
||||||
search: undefined,
|
search: undefined,
|
||||||
limit: 50,
|
limit: 50,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
@@ -361,7 +361,8 @@ describe('Knowledge Base Documents API Route', () => {
|
|||||||
expect(vi.mocked(createSingleDocument)).toHaveBeenCalledWith(
|
expect(vi.mocked(createSingleDocument)).toHaveBeenCalledWith(
|
||||||
validDocumentData,
|
validDocumentData,
|
||||||
'kb-123',
|
'kb-123',
|
||||||
expect.any(String)
|
expect.any(String),
|
||||||
|
'user-123'
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -469,7 +470,8 @@ describe('Knowledge Base Documents API Route', () => {
|
|||||||
expect(vi.mocked(createDocumentRecords)).toHaveBeenCalledWith(
|
expect(vi.mocked(createDocumentRecords)).toHaveBeenCalledWith(
|
||||||
validBulkData.documents,
|
validBulkData.documents,
|
||||||
'kb-123',
|
'kb-123',
|
||||||
expect.any(String)
|
expect.any(String),
|
||||||
|
'user-123'
|
||||||
)
|
)
|
||||||
expect(vi.mocked(processDocumentsWithQueue)).toHaveBeenCalled()
|
expect(vi.mocked(processDocumentsWithQueue)).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { z } from 'zod'
|
|||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import {
|
import {
|
||||||
bulkDocumentOperation,
|
bulkDocumentOperation,
|
||||||
bulkDocumentOperationByFilter,
|
|
||||||
createDocumentRecords,
|
createDocumentRecords,
|
||||||
createSingleDocument,
|
createSingleDocument,
|
||||||
getDocuments,
|
getDocuments,
|
||||||
@@ -58,19 +57,12 @@ const BulkCreateDocumentsSchema = z.object({
|
|||||||
bulk: z.literal(true),
|
bulk: z.literal(true),
|
||||||
})
|
})
|
||||||
|
|
||||||
const BulkUpdateDocumentsSchema = z
|
const BulkUpdateDocumentsSchema = z.object({
|
||||||
.object({
|
|
||||||
operation: z.enum(['enable', 'disable', 'delete']),
|
operation: z.enum(['enable', 'disable', 'delete']),
|
||||||
documentIds: z
|
documentIds: z
|
||||||
.array(z.string())
|
.array(z.string())
|
||||||
.min(1, 'At least one document ID is required')
|
.min(1, 'At least one document ID is required')
|
||||||
.max(100, 'Cannot operate on more than 100 documents at once')
|
.max(100, 'Cannot operate on more than 100 documents at once'),
|
||||||
.optional(),
|
|
||||||
selectAll: z.boolean().optional(),
|
|
||||||
enabledFilter: z.enum(['all', 'enabled', 'disabled']).optional(),
|
|
||||||
})
|
|
||||||
.refine((data) => data.selectAll || (data.documentIds && data.documentIds.length > 0), {
|
|
||||||
message: 'Either selectAll must be true or documentIds must be provided',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
@@ -98,17 +90,14 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
|
|||||||
}
|
}
|
||||||
|
|
||||||
const url = new URL(req.url)
|
const url = new URL(req.url)
|
||||||
const enabledFilter = url.searchParams.get('enabledFilter') as
|
const includeDisabled = url.searchParams.get('includeDisabled') === 'true'
|
||||||
| 'all'
|
|
||||||
| 'enabled'
|
|
||||||
| 'disabled'
|
|
||||||
| null
|
|
||||||
const search = url.searchParams.get('search') || undefined
|
const search = url.searchParams.get('search') || undefined
|
||||||
const limit = Number.parseInt(url.searchParams.get('limit') || '50')
|
const limit = Number.parseInt(url.searchParams.get('limit') || '50')
|
||||||
const offset = Number.parseInt(url.searchParams.get('offset') || '0')
|
const offset = Number.parseInt(url.searchParams.get('offset') || '0')
|
||||||
const sortByParam = url.searchParams.get('sortBy')
|
const sortByParam = url.searchParams.get('sortBy')
|
||||||
const sortOrderParam = url.searchParams.get('sortOrder')
|
const sortOrderParam = url.searchParams.get('sortOrder')
|
||||||
|
|
||||||
|
// Validate sort parameters
|
||||||
const validSortFields: DocumentSortField[] = [
|
const validSortFields: DocumentSortField[] = [
|
||||||
'filename',
|
'filename',
|
||||||
'fileSize',
|
'fileSize',
|
||||||
@@ -116,7 +105,6 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
|
|||||||
'chunkCount',
|
'chunkCount',
|
||||||
'uploadedAt',
|
'uploadedAt',
|
||||||
'processingStatus',
|
'processingStatus',
|
||||||
'enabled',
|
|
||||||
]
|
]
|
||||||
const validSortOrders: SortOrder[] = ['asc', 'desc']
|
const validSortOrders: SortOrder[] = ['asc', 'desc']
|
||||||
|
|
||||||
@@ -132,7 +120,7 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
|
|||||||
const result = await getDocuments(
|
const result = await getDocuments(
|
||||||
knowledgeBaseId,
|
knowledgeBaseId,
|
||||||
{
|
{
|
||||||
enabledFilter: enabledFilter || undefined,
|
includeDisabled,
|
||||||
search,
|
search,
|
||||||
limit,
|
limit,
|
||||||
offset,
|
offset,
|
||||||
@@ -202,7 +190,8 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
|||||||
const createdDocuments = await createDocumentRecords(
|
const createdDocuments = await createDocumentRecords(
|
||||||
validatedData.documents,
|
validatedData.documents,
|
||||||
knowledgeBaseId,
|
knowledgeBaseId,
|
||||||
requestId
|
requestId,
|
||||||
|
userId
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -261,10 +250,16 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
|||||||
throw validationError
|
throw validationError
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// Handle single document creation
|
||||||
try {
|
try {
|
||||||
const validatedData = CreateDocumentSchema.parse(body)
|
const validatedData = CreateDocumentSchema.parse(body)
|
||||||
|
|
||||||
const newDocument = await createSingleDocument(validatedData, knowledgeBaseId, requestId)
|
const newDocument = await createSingleDocument(
|
||||||
|
validatedData,
|
||||||
|
knowledgeBaseId,
|
||||||
|
requestId,
|
||||||
|
userId
|
||||||
|
)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { PlatformEvents } = await import('@/lib/core/telemetry')
|
const { PlatformEvents } = await import('@/lib/core/telemetry')
|
||||||
@@ -299,6 +294,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`[${requestId}] Error creating document`, error)
|
logger.error(`[${requestId}] Error creating document`, error)
|
||||||
|
|
||||||
|
// Check if it's a storage limit error
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Failed to create document'
|
const errorMessage = error instanceof Error ? error.message : 'Failed to create document'
|
||||||
const isStorageLimitError =
|
const isStorageLimitError =
|
||||||
errorMessage.includes('Storage limit exceeded') || errorMessage.includes('storage limit')
|
errorMessage.includes('Storage limit exceeded') || errorMessage.includes('storage limit')
|
||||||
@@ -335,22 +331,16 @@ export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const validatedData = BulkUpdateDocumentsSchema.parse(body)
|
const validatedData = BulkUpdateDocumentsSchema.parse(body)
|
||||||
const { operation, documentIds, selectAll, enabledFilter } = validatedData
|
const { operation, documentIds } = validatedData
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let result
|
const result = await bulkDocumentOperation(
|
||||||
if (selectAll) {
|
|
||||||
result = await bulkDocumentOperationByFilter(
|
|
||||||
knowledgeBaseId,
|
knowledgeBaseId,
|
||||||
operation,
|
operation,
|
||||||
enabledFilter,
|
documentIds,
|
||||||
requestId
|
requestId,
|
||||||
|
session.user.id
|
||||||
)
|
)
|
||||||
} else if (documentIds && documentIds.length > 0) {
|
|
||||||
result = await bulkDocumentOperation(knowledgeBaseId, operation, documentIds, requestId)
|
|
||||||
} else {
|
|
||||||
return NextResponse.json({ error: 'No documents specified' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import type { NextRequest } from 'next/server'
|
import type { NextRequest } from 'next/server'
|
||||||
|
import { getEffectiveDecryptedEnv } from '@/lib/environment/utils'
|
||||||
import { McpClient } from '@/lib/mcp/client'
|
import { McpClient } from '@/lib/mcp/client'
|
||||||
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
|
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
|
||||||
import { resolveMcpConfigEnvVars } from '@/lib/mcp/resolve-config'
|
import type { McpServerConfig, McpTransport } from '@/lib/mcp/types'
|
||||||
import type { McpTransport } from '@/lib/mcp/types'
|
|
||||||
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
|
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
|
||||||
|
import { resolveEnvVarReferences } from '@/executor/utils/reference-validation'
|
||||||
|
|
||||||
const logger = createLogger('McpServerTestAPI')
|
const logger = createLogger('McpServerTestAPI')
|
||||||
|
|
||||||
@@ -18,6 +19,30 @@ function isUrlBasedTransport(transport: McpTransport): boolean {
|
|||||||
return transport === 'streamable-http'
|
return transport === 'streamable-http'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve environment variables in strings
|
||||||
|
*/
|
||||||
|
function resolveEnvVars(value: string, envVars: Record<string, string>): string {
|
||||||
|
const missingVars: string[] = []
|
||||||
|
const resolvedValue = resolveEnvVarReferences(value, envVars, {
|
||||||
|
allowEmbedded: true,
|
||||||
|
resolveExactMatch: true,
|
||||||
|
trimKeys: true,
|
||||||
|
onMissing: 'keep',
|
||||||
|
deep: false,
|
||||||
|
missingKeys: missingVars,
|
||||||
|
}) as string
|
||||||
|
|
||||||
|
if (missingVars.length > 0) {
|
||||||
|
const uniqueMissing = Array.from(new Set(missingVars))
|
||||||
|
uniqueMissing.forEach((envKey) => {
|
||||||
|
logger.warn(`Environment variable "${envKey}" not found in MCP server test`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolvedValue
|
||||||
|
}
|
||||||
|
|
||||||
interface TestConnectionRequest {
|
interface TestConnectionRequest {
|
||||||
name: string
|
name: string
|
||||||
transport: McpTransport
|
transport: McpTransport
|
||||||
@@ -71,30 +96,39 @@ export const POST = withMcpAuth('write')(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build initial config for resolution
|
let resolvedUrl = body.url
|
||||||
const initialConfig = {
|
let resolvedHeaders = body.headers || {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const envVars = await getEffectiveDecryptedEnv(userId, workspaceId)
|
||||||
|
|
||||||
|
if (resolvedUrl) {
|
||||||
|
resolvedUrl = resolveEnvVars(resolvedUrl, envVars)
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedHeadersObj: Record<string, string> = {}
|
||||||
|
for (const [key, value] of Object.entries(resolvedHeaders)) {
|
||||||
|
resolvedHeadersObj[key] = resolveEnvVars(value, envVars)
|
||||||
|
}
|
||||||
|
resolvedHeaders = resolvedHeadersObj
|
||||||
|
} catch (envError) {
|
||||||
|
logger.warn(
|
||||||
|
`[${requestId}] Failed to resolve environment variables, using raw values:`,
|
||||||
|
envError
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const testConfig: McpServerConfig = {
|
||||||
id: `test-${requestId}`,
|
id: `test-${requestId}`,
|
||||||
name: body.name,
|
name: body.name,
|
||||||
transport: body.transport,
|
transport: body.transport,
|
||||||
url: body.url,
|
url: resolvedUrl,
|
||||||
headers: body.headers || {},
|
headers: resolvedHeaders,
|
||||||
timeout: body.timeout || 10000,
|
timeout: body.timeout || 10000,
|
||||||
retries: 1, // Only one retry for tests
|
retries: 1, // Only one retry for tests
|
||||||
enabled: true,
|
enabled: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve env vars using shared utility (non-strict mode for testing)
|
|
||||||
const { config: testConfig, missingVars } = await resolveMcpConfigEnvVars(
|
|
||||||
initialConfig,
|
|
||||||
userId,
|
|
||||||
workspaceId,
|
|
||||||
{ strict: false }
|
|
||||||
)
|
|
||||||
|
|
||||||
if (missingVars.length > 0) {
|
|
||||||
logger.warn(`[${requestId}] Some environment variables not found:`, { missingVars })
|
|
||||||
}
|
|
||||||
|
|
||||||
const testSecurityPolicy = {
|
const testSecurityPolicy = {
|
||||||
requireConsent: false,
|
requireConsent: false,
|
||||||
auditLevel: 'none' as const,
|
auditLevel: 'none' as const,
|
||||||
|
|||||||
@@ -3,9 +3,7 @@ import { account } from '@sim/db/schema'
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { eq } from 'drizzle-orm'
|
import { eq } from 'drizzle-orm'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
|
|
||||||
import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||||
import type { StreamingExecution } from '@/executor/types'
|
import type { StreamingExecution } from '@/executor/types'
|
||||||
import { executeProviderRequest } from '@/providers'
|
import { executeProviderRequest } from '@/providers'
|
||||||
@@ -22,11 +20,6 @@ export async function POST(request: NextRequest) {
|
|||||||
const startTime = Date.now()
|
const startTime = Date.now()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const auth = await checkInternalAuth(request, { requireWorkflowId: false })
|
|
||||||
if (!auth.success || !auth.userId) {
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`[${requestId}] Provider API request started`, {
|
logger.info(`[${requestId}] Provider API request started`, {
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
userAgent: request.headers.get('User-Agent'),
|
userAgent: request.headers.get('User-Agent'),
|
||||||
@@ -92,13 +85,6 @@ export async function POST(request: NextRequest) {
|
|||||||
verbosity,
|
verbosity,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (workspaceId) {
|
|
||||||
const workspaceAccess = await checkWorkspaceAccess(workspaceId, auth.userId)
|
|
||||||
if (!workspaceAccess.hasAccess) {
|
|
||||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let finalApiKey: string | undefined = apiKey
|
let finalApiKey: string | undefined = apiKey
|
||||||
try {
|
try {
|
||||||
if (provider === 'vertex' && vertexCredential) {
|
if (provider === 'vertex' && vertexCredential) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||||
import { validateImageUrl } from '@/lib/core/security/input-validation'
|
import { validateImageUrl } from '@/lib/core/security/input-validation'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ export async function GET(request: NextRequest) {
|
|||||||
const imageUrl = url.searchParams.get('url')
|
const imageUrl = url.searchParams.get('url')
|
||||||
const requestId = generateRequestId()
|
const requestId = generateRequestId()
|
||||||
|
|
||||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||||
if (!authResult.success) {
|
if (!authResult.success) {
|
||||||
logger.error(`[${requestId}] Authentication failed for image proxy:`, authResult.error)
|
logger.error(`[${requestId}] Authentication failed for image proxy:`, authResult.error)
|
||||||
return new NextResponse('Unauthorized', { status: 401 })
|
return new NextResponse('Unauthorized', { status: 401 })
|
||||||
395
apps/sim/app/api/proxy/route.ts
Normal file
395
apps/sim/app/api/proxy/route.ts
Normal file
@@ -0,0 +1,395 @@
|
|||||||
|
import { createLogger } from '@sim/logger'
|
||||||
|
import type { NextRequest } from 'next/server'
|
||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||||
|
import { generateInternalToken } from '@/lib/auth/internal'
|
||||||
|
import { isDev } from '@/lib/core/config/feature-flags'
|
||||||
|
import { createPinnedUrl, validateUrlWithDNS } from '@/lib/core/security/input-validation'
|
||||||
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
|
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||||
|
import { executeTool } from '@/tools'
|
||||||
|
import { getTool, validateRequiredParametersAfterMerge } from '@/tools/utils'
|
||||||
|
|
||||||
|
const logger = createLogger('ProxyAPI')
|
||||||
|
|
||||||
|
const proxyPostSchema = z.object({
|
||||||
|
toolId: z.string().min(1, 'toolId is required'),
|
||||||
|
params: z.record(z.any()).optional().default({}),
|
||||||
|
executionContext: z
|
||||||
|
.object({
|
||||||
|
workflowId: z.string().optional(),
|
||||||
|
workspaceId: z.string().optional(),
|
||||||
|
executionId: z.string().optional(),
|
||||||
|
userId: z.string().optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a minimal set of default headers for proxy requests
|
||||||
|
* @returns Record of HTTP headers
|
||||||
|
*/
|
||||||
|
const getProxyHeaders = (): Record<string, string> => {
|
||||||
|
return {
|
||||||
|
'User-Agent':
|
||||||
|
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36',
|
||||||
|
Accept: '*/*',
|
||||||
|
'Accept-Encoding': 'gzip, deflate, br',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
Connection: 'keep-alive',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a response with CORS headers
|
||||||
|
* @param responseData Response data object
|
||||||
|
* @param status HTTP status code
|
||||||
|
* @returns NextResponse with CORS headers
|
||||||
|
*/
|
||||||
|
const formatResponse = (responseData: any, status = 200) => {
|
||||||
|
return NextResponse.json(responseData, {
|
||||||
|
status,
|
||||||
|
headers: {
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an error response with consistent formatting
|
||||||
|
* @param error Error object or message
|
||||||
|
* @param status HTTP status code
|
||||||
|
* @param additionalData Additional data to include in the response
|
||||||
|
* @returns Formatted error response
|
||||||
|
*/
|
||||||
|
const createErrorResponse = (error: any, status = 500, additionalData = {}) => {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||||
|
const errorStack = error instanceof Error ? error.stack : undefined
|
||||||
|
|
||||||
|
logger.error('Creating error response', {
|
||||||
|
errorMessage,
|
||||||
|
status,
|
||||||
|
stack: isDev ? errorStack : undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
return formatResponse(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: errorMessage,
|
||||||
|
stack: isDev ? errorStack : undefined,
|
||||||
|
...additionalData,
|
||||||
|
},
|
||||||
|
status
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET handler for direct external URL proxying
|
||||||
|
* This allows for GET requests to external APIs
|
||||||
|
*/
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const url = new URL(request.url)
|
||||||
|
const targetUrl = url.searchParams.get('url')
|
||||||
|
const requestId = generateRequestId()
|
||||||
|
|
||||||
|
// Vault download proxy: /api/proxy?vaultDownload=1&bucket=...&object=...&credentialId=...
|
||||||
|
const vaultDownload = url.searchParams.get('vaultDownload')
|
||||||
|
if (vaultDownload === '1') {
|
||||||
|
try {
|
||||||
|
const bucket = url.searchParams.get('bucket')
|
||||||
|
const objectParam = url.searchParams.get('object')
|
||||||
|
const credentialId = url.searchParams.get('credentialId')
|
||||||
|
|
||||||
|
if (!bucket || !objectParam || !credentialId) {
|
||||||
|
return createErrorResponse('Missing bucket, object, or credentialId', 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch access token using existing token API
|
||||||
|
const baseUrl = new URL(getBaseUrl())
|
||||||
|
const tokenUrl = new URL('/api/auth/oauth/token', baseUrl)
|
||||||
|
|
||||||
|
// Build headers: forward session cookies if present; include internal auth for server-side
|
||||||
|
const tokenHeaders: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||||
|
const incomingCookie = request.headers.get('cookie')
|
||||||
|
if (incomingCookie) tokenHeaders.Cookie = incomingCookie
|
||||||
|
try {
|
||||||
|
const internalToken = await generateInternalToken()
|
||||||
|
tokenHeaders.Authorization = `Bearer ${internalToken}`
|
||||||
|
} catch (_e) {
|
||||||
|
// best-effort internal auth
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional workflow context for collaboration auth
|
||||||
|
const workflowId = url.searchParams.get('workflowId') || undefined
|
||||||
|
|
||||||
|
const tokenRes = await fetch(tokenUrl.toString(), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: tokenHeaders,
|
||||||
|
body: JSON.stringify({ credentialId, workflowId }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!tokenRes.ok) {
|
||||||
|
const err = await tokenRes.text()
|
||||||
|
return createErrorResponse(`Failed to fetch access token: ${err}`, 401)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenJson = await tokenRes.json()
|
||||||
|
const accessToken = tokenJson.accessToken
|
||||||
|
if (!accessToken) {
|
||||||
|
return createErrorResponse('No access token available', 401)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Avoid double-encoding: incoming object may already be percent-encoded
|
||||||
|
const objectDecoded = decodeURIComponent(objectParam)
|
||||||
|
const gcsUrl = `https://storage.googleapis.com/storage/v1/b/${encodeURIComponent(
|
||||||
|
bucket
|
||||||
|
)}/o/${encodeURIComponent(objectDecoded)}?alt=media`
|
||||||
|
|
||||||
|
const fileRes = await fetch(gcsUrl, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!fileRes.ok) {
|
||||||
|
const errText = await fileRes.text()
|
||||||
|
return createErrorResponse(errText || 'Failed to download file', fileRes.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = new Headers()
|
||||||
|
fileRes.headers.forEach((v, k) => headers.set(k, v))
|
||||||
|
return new NextResponse(fileRes.body, { status: 200, headers })
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error(`[${requestId}] Vault download proxy failed`, {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
})
|
||||||
|
return createErrorResponse('Vault download failed', 500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetUrl) {
|
||||||
|
logger.error(`[${requestId}] Missing 'url' parameter`)
|
||||||
|
return createErrorResponse("Missing 'url' parameter", 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
const urlValidation = await validateUrlWithDNS(targetUrl)
|
||||||
|
if (!urlValidation.isValid) {
|
||||||
|
logger.warn(`[${requestId}] Blocked proxy request`, {
|
||||||
|
url: targetUrl.substring(0, 100),
|
||||||
|
error: urlValidation.error,
|
||||||
|
})
|
||||||
|
return createErrorResponse(urlValidation.error || 'Invalid URL', 403)
|
||||||
|
}
|
||||||
|
|
||||||
|
const method = url.searchParams.get('method') || 'GET'
|
||||||
|
|
||||||
|
const bodyParam = url.searchParams.get('body')
|
||||||
|
let body: string | undefined
|
||||||
|
|
||||||
|
if (bodyParam && ['POST', 'PUT', 'PATCH'].includes(method.toUpperCase())) {
|
||||||
|
try {
|
||||||
|
body = decodeURIComponent(bodyParam)
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`[${requestId}] Failed to decode body parameter`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const customHeaders: Record<string, string> = {}
|
||||||
|
|
||||||
|
for (const [key, value] of url.searchParams.entries()) {
|
||||||
|
if (key.startsWith('header.')) {
|
||||||
|
const headerName = key.substring(7)
|
||||||
|
customHeaders[headerName] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body && !customHeaders['Content-Type']) {
|
||||||
|
customHeaders['Content-Type'] = 'application/json'
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`[${requestId}] Proxying ${method} request to: ${targetUrl}`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pinnedUrl = createPinnedUrl(targetUrl, urlValidation.resolvedIP!)
|
||||||
|
const response = await fetch(pinnedUrl, {
|
||||||
|
method: method,
|
||||||
|
headers: {
|
||||||
|
...getProxyHeaders(),
|
||||||
|
...customHeaders,
|
||||||
|
Host: urlValidation.originalHostname!,
|
||||||
|
},
|
||||||
|
body: body || undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
const contentType = response.headers.get('content-type') || ''
|
||||||
|
let data
|
||||||
|
|
||||||
|
if (contentType.includes('application/json')) {
|
||||||
|
data = await response.json()
|
||||||
|
} else {
|
||||||
|
data = await response.text()
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorMessage = !response.ok
|
||||||
|
? data && typeof data === 'object' && data.error
|
||||||
|
? `${data.error.message || JSON.stringify(data.error)}`
|
||||||
|
: response.statusText || `HTTP error ${response.status}`
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
logger.error(`[${requestId}] External API error: ${response.status} ${response.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatResponse({
|
||||||
|
success: response.ok,
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
headers: Object.fromEntries(response.headers.entries()),
|
||||||
|
data,
|
||||||
|
error: errorMessage,
|
||||||
|
})
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error(`[${requestId}] Proxy GET request failed`, {
|
||||||
|
url: targetUrl,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
return createErrorResponse(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const requestId = generateRequestId()
|
||||||
|
const startTime = new Date()
|
||||||
|
const startTimeISO = startTime.toISOString()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||||
|
if (!authResult.success) {
|
||||||
|
logger.error(`[${requestId}] Authentication failed for proxy:`, authResult.error)
|
||||||
|
return createErrorResponse('Unauthorized', 401)
|
||||||
|
}
|
||||||
|
|
||||||
|
let requestBody
|
||||||
|
try {
|
||||||
|
requestBody = await request.json()
|
||||||
|
} catch (parseError) {
|
||||||
|
logger.error(`[${requestId}] Failed to parse request body`, {
|
||||||
|
error: parseError instanceof Error ? parseError.message : String(parseError),
|
||||||
|
})
|
||||||
|
throw new Error('Invalid JSON in request body')
|
||||||
|
}
|
||||||
|
|
||||||
|
const validationResult = proxyPostSchema.safeParse(requestBody)
|
||||||
|
if (!validationResult.success) {
|
||||||
|
logger.error(`[${requestId}] Request validation failed`, {
|
||||||
|
errors: validationResult.error.errors,
|
||||||
|
})
|
||||||
|
const errorMessages = validationResult.error.errors
|
||||||
|
.map((err) => `${err.path.join('.')}: ${err.message}`)
|
||||||
|
.join(', ')
|
||||||
|
throw new Error(`Validation failed: ${errorMessages}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { toolId, params } = validationResult.data
|
||||||
|
|
||||||
|
logger.info(`[${requestId}] Processing tool: ${toolId}`)
|
||||||
|
|
||||||
|
const tool = getTool(toolId)
|
||||||
|
|
||||||
|
if (!tool) {
|
||||||
|
logger.error(`[${requestId}] Tool not found: ${toolId}`)
|
||||||
|
throw new Error(`Tool not found: ${toolId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
validateRequiredParametersAfterMerge(toolId, tool, params)
|
||||||
|
} catch (validationError) {
|
||||||
|
logger.warn(`[${requestId}] Tool validation failed for ${toolId}`, {
|
||||||
|
error: validationError instanceof Error ? validationError.message : String(validationError),
|
||||||
|
})
|
||||||
|
|
||||||
|
const endTime = new Date()
|
||||||
|
const endTimeISO = endTime.toISOString()
|
||||||
|
const duration = endTime.getTime() - startTime.getTime()
|
||||||
|
|
||||||
|
return createErrorResponse(validationError, 400, {
|
||||||
|
startTime: startTimeISO,
|
||||||
|
endTime: endTimeISO,
|
||||||
|
duration,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasFileOutputs =
|
||||||
|
tool.outputs &&
|
||||||
|
Object.values(tool.outputs).some(
|
||||||
|
(output) => output.type === 'file' || output.type === 'file[]'
|
||||||
|
)
|
||||||
|
|
||||||
|
const result = await executeTool(
|
||||||
|
toolId,
|
||||||
|
params,
|
||||||
|
true, // skipProxy (we're already in the proxy)
|
||||||
|
!hasFileOutputs, // skipPostProcess (don't skip if tool has file outputs)
|
||||||
|
undefined // execution context is not available in proxy context
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
logger.warn(`[${requestId}] Tool execution failed for ${toolId}`, {
|
||||||
|
error: result.error || 'Unknown error',
|
||||||
|
})
|
||||||
|
|
||||||
|
throw new Error(result.error || 'Tool execution failed')
|
||||||
|
}
|
||||||
|
|
||||||
|
const endTime = new Date()
|
||||||
|
const endTimeISO = endTime.toISOString()
|
||||||
|
const duration = endTime.getTime() - startTime.getTime()
|
||||||
|
|
||||||
|
const responseWithTimingData = {
|
||||||
|
...result,
|
||||||
|
startTime: startTimeISO,
|
||||||
|
endTime: endTimeISO,
|
||||||
|
duration,
|
||||||
|
timing: {
|
||||||
|
startTime: startTimeISO,
|
||||||
|
endTime: endTimeISO,
|
||||||
|
duration,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`[${requestId}] Tool executed successfully: ${toolId} (${duration}ms)`)
|
||||||
|
|
||||||
|
return formatResponse(responseWithTimingData)
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error(`[${requestId}] Proxy request failed`, {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
name: error instanceof Error ? error.name : undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
const endTime = new Date()
|
||||||
|
const endTimeISO = endTime.toISOString()
|
||||||
|
const duration = endTime.getTime() - startTime.getTime()
|
||||||
|
|
||||||
|
return createErrorResponse(error, 500, {
|
||||||
|
startTime: startTimeISO,
|
||||||
|
endTime: endTimeISO,
|
||||||
|
duration,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function OPTIONS() {
|
||||||
|
return new NextResponse(null, {
|
||||||
|
status: 204,
|
||||||
|
headers: {
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||||
|
'Access-Control-Max-Age': '86400',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { extractAudioFromVideo, isVideoFile } from '@/lib/audio/extractor'
|
import { extractAudioFromVideo, isVideoFile } from '@/lib/audio/extractor'
|
||||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||||
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
|
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
|
||||||
import type { UserFile } from '@/executor/types'
|
import type { UserFile } from '@/executor/types'
|
||||||
import type { TranscriptSegment } from '@/tools/stt/types'
|
import type { TranscriptSegment } from '@/tools/stt/types'
|
||||||
@@ -40,7 +40,7 @@ export async function POST(request: NextRequest) {
|
|||||||
logger.info(`[${requestId}] STT transcription request started`)
|
logger.info(`[${requestId}] STT transcription request started`)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||||
if (!authResult.success) {
|
if (!authResult.success) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import type { NextRequest } from 'next/server'
|
import type { NextRequest } from 'next/server'
|
||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||||
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
|
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
|
||||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||||
import { StorageService } from '@/lib/uploads'
|
import { StorageService } from '@/lib/uploads'
|
||||||
@@ -10,7 +10,7 @@ const logger = createLogger('ProxyTTSAPI')
|
|||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||||
if (!authResult.success) {
|
if (!authResult.success) {
|
||||||
logger.error('Authentication failed for TTS proxy:', authResult.error)
|
logger.error('Authentication failed for TTS proxy:', authResult.error)
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import type { NextRequest } from 'next/server'
|
import type { NextRequest } from 'next/server'
|
||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||||
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
|
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
|
||||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||||
import { StorageService } from '@/lib/uploads'
|
import { StorageService } from '@/lib/uploads'
|
||||||
@@ -87,7 +87,7 @@ export async function POST(request: NextRequest) {
|
|||||||
logger.info(`[${requestId}] TTS unified request started`)
|
logger.info(`[${requestId}] TTS unified request started`)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||||
if (!authResult.success) {
|
if (!authResult.success) {
|
||||||
logger.error('Authentication failed for TTS unified proxy:', authResult.error)
|
logger.error('Authentication failed for TTS unified proxy:', authResult.error)
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||||
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
|
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
|
||||||
import type { UserFile } from '@/executor/types'
|
import type { UserFile } from '@/executor/types'
|
||||||
import type { VideoRequestBody } from '@/tools/video/types'
|
import type { VideoRequestBody } from '@/tools/video/types'
|
||||||
@@ -15,7 +15,7 @@ export async function POST(request: NextRequest) {
|
|||||||
logger.info(`[${requestId}] Video generation request started`)
|
logger.info(`[${requestId}] Video generation request started`)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||||
if (!authResult.success) {
|
if (!authResult.success) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
import { db } from '@sim/db'
|
|
||||||
import { copilotChats, workflow, workspace } from '@sim/db/schema'
|
|
||||||
import { createLogger } from '@sim/logger'
|
|
||||||
import { eq } from 'drizzle-orm'
|
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
|
||||||
import { getSession } from '@/lib/auth'
|
|
||||||
import { verifyEffectiveSuperUser } from '@/lib/templates/permissions'
|
|
||||||
import { parseWorkflowJson } from '@/lib/workflows/operations/import-export'
|
|
||||||
import {
|
|
||||||
loadWorkflowFromNormalizedTables,
|
|
||||||
saveWorkflowToNormalizedTables,
|
|
||||||
} from '@/lib/workflows/persistence/utils'
|
|
||||||
import { sanitizeForExport } from '@/lib/workflows/sanitization/json-sanitizer'
|
|
||||||
|
|
||||||
const logger = createLogger('SuperUserImportWorkflow')
|
|
||||||
|
|
||||||
interface ImportWorkflowRequest {
|
|
||||||
workflowId: string
|
|
||||||
targetWorkspaceId: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/superuser/import-workflow
|
|
||||||
*
|
|
||||||
* Superuser endpoint to import a workflow by ID along with its copilot chats.
|
|
||||||
* This creates a copy of the workflow in the target workspace with new IDs.
|
|
||||||
* Only the workflow structure and copilot chats are copied - no deployments,
|
|
||||||
* webhooks, triggers, or other sensitive data.
|
|
||||||
*
|
|
||||||
* Requires both isSuperUser flag AND superUserModeEnabled setting.
|
|
||||||
*/
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const session = await getSession()
|
|
||||||
if (!session?.user?.id) {
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const { effectiveSuperUser, isSuperUser, superUserModeEnabled } =
|
|
||||||
await verifyEffectiveSuperUser(session.user.id)
|
|
||||||
|
|
||||||
if (!effectiveSuperUser) {
|
|
||||||
logger.warn('Non-effective-superuser attempted to access import-workflow endpoint', {
|
|
||||||
userId: session.user.id,
|
|
||||||
isSuperUser,
|
|
||||||
superUserModeEnabled,
|
|
||||||
})
|
|
||||||
return NextResponse.json({ error: 'Forbidden: Superuser access required' }, { status: 403 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const body: ImportWorkflowRequest = await request.json()
|
|
||||||
const { workflowId, targetWorkspaceId } = body
|
|
||||||
|
|
||||||
if (!workflowId) {
|
|
||||||
return NextResponse.json({ error: 'workflowId is required' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!targetWorkspaceId) {
|
|
||||||
return NextResponse.json({ error: 'targetWorkspaceId is required' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify target workspace exists
|
|
||||||
const [targetWorkspace] = await db
|
|
||||||
.select({ id: workspace.id, ownerId: workspace.ownerId })
|
|
||||||
.from(workspace)
|
|
||||||
.where(eq(workspace.id, targetWorkspaceId))
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
if (!targetWorkspace) {
|
|
||||||
return NextResponse.json({ error: 'Target workspace not found' }, { status: 404 })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the source workflow
|
|
||||||
const [sourceWorkflow] = await db
|
|
||||||
.select()
|
|
||||||
.from(workflow)
|
|
||||||
.where(eq(workflow.id, workflowId))
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
if (!sourceWorkflow) {
|
|
||||||
return NextResponse.json({ error: 'Source workflow not found' }, { status: 404 })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load the workflow state from normalized tables
|
|
||||||
const normalizedData = await loadWorkflowFromNormalizedTables(workflowId)
|
|
||||||
|
|
||||||
if (!normalizedData) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Workflow has no normalized data - cannot import' },
|
|
||||||
{ status: 400 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use existing export logic to create export format
|
|
||||||
const workflowState = {
|
|
||||||
blocks: normalizedData.blocks,
|
|
||||||
edges: normalizedData.edges,
|
|
||||||
loops: normalizedData.loops,
|
|
||||||
parallels: normalizedData.parallels,
|
|
||||||
metadata: {
|
|
||||||
name: sourceWorkflow.name,
|
|
||||||
description: sourceWorkflow.description ?? undefined,
|
|
||||||
color: sourceWorkflow.color,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const exportData = sanitizeForExport(workflowState)
|
|
||||||
|
|
||||||
// Use existing import logic (parseWorkflowJson regenerates IDs automatically)
|
|
||||||
const { data: importedData, errors } = parseWorkflowJson(JSON.stringify(exportData))
|
|
||||||
|
|
||||||
if (!importedData || errors.length > 0) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: `Failed to parse workflow: ${errors.join(', ')}` },
|
|
||||||
{ status: 400 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create new workflow record
|
|
||||||
const newWorkflowId = crypto.randomUUID()
|
|
||||||
const now = new Date()
|
|
||||||
|
|
||||||
await db.insert(workflow).values({
|
|
||||||
id: newWorkflowId,
|
|
||||||
userId: session.user.id,
|
|
||||||
workspaceId: targetWorkspaceId,
|
|
||||||
folderId: null, // Don't copy folder association
|
|
||||||
name: `[Debug Import] ${sourceWorkflow.name}`,
|
|
||||||
description: sourceWorkflow.description,
|
|
||||||
color: sourceWorkflow.color,
|
|
||||||
lastSynced: now,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
isDeployed: false, // Never copy deployment status
|
|
||||||
runCount: 0,
|
|
||||||
variables: sourceWorkflow.variables || {},
|
|
||||||
})
|
|
||||||
|
|
||||||
// Save using existing persistence logic
|
|
||||||
const saveResult = await saveWorkflowToNormalizedTables(newWorkflowId, importedData)
|
|
||||||
|
|
||||||
if (!saveResult.success) {
|
|
||||||
// Clean up the workflow record if save failed
|
|
||||||
await db.delete(workflow).where(eq(workflow.id, newWorkflowId))
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: `Failed to save workflow state: ${saveResult.error}` },
|
|
||||||
{ status: 500 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copy copilot chats associated with the source workflow
|
|
||||||
const sourceCopilotChats = await db
|
|
||||||
.select()
|
|
||||||
.from(copilotChats)
|
|
||||||
.where(eq(copilotChats.workflowId, workflowId))
|
|
||||||
|
|
||||||
let copilotChatsImported = 0
|
|
||||||
|
|
||||||
for (const chat of sourceCopilotChats) {
|
|
||||||
await db.insert(copilotChats).values({
|
|
||||||
userId: session.user.id,
|
|
||||||
workflowId: newWorkflowId,
|
|
||||||
title: chat.title ? `[Import] ${chat.title}` : null,
|
|
||||||
messages: chat.messages,
|
|
||||||
model: chat.model,
|
|
||||||
conversationId: null, // Don't copy conversation ID
|
|
||||||
previewYaml: chat.previewYaml,
|
|
||||||
planArtifact: chat.planArtifact,
|
|
||||||
config: chat.config,
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
})
|
|
||||||
copilotChatsImported++
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info('Superuser imported workflow', {
|
|
||||||
userId: session.user.id,
|
|
||||||
sourceWorkflowId: workflowId,
|
|
||||||
newWorkflowId,
|
|
||||||
targetWorkspaceId,
|
|
||||||
copilotChatsImported,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
newWorkflowId,
|
|
||||||
copilotChatsImported,
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error importing workflow', error)
|
|
||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,7 @@ import { eq } from 'drizzle-orm'
|
|||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
import { verifyEffectiveSuperUser } from '@/lib/templates/permissions'
|
import { verifySuperUser } from '@/lib/templates/permissions'
|
||||||
|
|
||||||
const logger = createLogger('TemplateApprovalAPI')
|
const logger = createLogger('TemplateApprovalAPI')
|
||||||
|
|
||||||
@@ -25,8 +25,8 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { effectiveSuperUser } = await verifyEffectiveSuperUser(session.user.id)
|
const { isSuperUser } = await verifySuperUser(session.user.id)
|
||||||
if (!effectiveSuperUser) {
|
if (!isSuperUser) {
|
||||||
logger.warn(`[${requestId}] Non-super user attempted to approve template: ${id}`)
|
logger.warn(`[${requestId}] Non-super user attempted to approve template: ${id}`)
|
||||||
return NextResponse.json({ error: 'Only super users can approve templates' }, { status: 403 })
|
return NextResponse.json({ error: 'Only super users can approve templates' }, { status: 403 })
|
||||||
}
|
}
|
||||||
@@ -71,8 +71,8 @@ export async function DELETE(
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { effectiveSuperUser } = await verifyEffectiveSuperUser(session.user.id)
|
const { isSuperUser } = await verifySuperUser(session.user.id)
|
||||||
if (!effectiveSuperUser) {
|
if (!isSuperUser) {
|
||||||
logger.warn(`[${requestId}] Non-super user attempted to reject template: ${id}`)
|
logger.warn(`[${requestId}] Non-super user attempted to reject template: ${id}`)
|
||||||
return NextResponse.json({ error: 'Only super users can reject templates' }, { status: 403 })
|
return NextResponse.json({ error: 'Only super users can reject templates' }, { status: 403 })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { eq } from 'drizzle-orm'
|
|||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
import { verifyEffectiveSuperUser } from '@/lib/templates/permissions'
|
import { verifySuperUser } from '@/lib/templates/permissions'
|
||||||
|
|
||||||
const logger = createLogger('TemplateRejectionAPI')
|
const logger = createLogger('TemplateRejectionAPI')
|
||||||
|
|
||||||
@@ -25,8 +25,8 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { effectiveSuperUser } = await verifyEffectiveSuperUser(session.user.id)
|
const { isSuperUser } = await verifySuperUser(session.user.id)
|
||||||
if (!effectiveSuperUser) {
|
if (!isSuperUser) {
|
||||||
logger.warn(`[${requestId}] Non-super user attempted to reject template: ${id}`)
|
logger.warn(`[${requestId}] Non-super user attempted to reject template: ${id}`)
|
||||||
return NextResponse.json({ error: 'Only super users can reject templates' }, { status: 403 })
|
return NextResponse.json({ error: 'Only super users can reject templates' }, { status: 403 })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
templateCreators,
|
templateCreators,
|
||||||
templateStars,
|
templateStars,
|
||||||
templates,
|
templates,
|
||||||
|
user,
|
||||||
workflow,
|
workflow,
|
||||||
workflowDeploymentVersion,
|
workflowDeploymentVersion,
|
||||||
} from '@sim/db/schema'
|
} from '@sim/db/schema'
|
||||||
@@ -13,7 +14,6 @@ import { v4 as uuidv4 } from 'uuid'
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
import { verifyEffectiveSuperUser } from '@/lib/templates/permissions'
|
|
||||||
import {
|
import {
|
||||||
extractRequiredCredentials,
|
extractRequiredCredentials,
|
||||||
sanitizeCredentials,
|
sanitizeCredentials,
|
||||||
@@ -70,8 +70,8 @@ export async function GET(request: NextRequest) {
|
|||||||
logger.debug(`[${requestId}] Fetching templates with params:`, params)
|
logger.debug(`[${requestId}] Fetching templates with params:`, params)
|
||||||
|
|
||||||
// Check if user is a super user
|
// Check if user is a super user
|
||||||
const { effectiveSuperUser } = await verifyEffectiveSuperUser(session.user.id)
|
const currentUser = await db.select().from(user).where(eq(user.id, session.user.id)).limit(1)
|
||||||
const isSuperUser = effectiveSuperUser
|
const isSuperUser = currentUser[0]?.isSuperUser || false
|
||||||
|
|
||||||
// Build query conditions
|
// Build query conditions
|
||||||
const conditions = []
|
const conditions = []
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { type NextRequest, NextResponse } from 'next/server'
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { createA2AClient } from '@/lib/a2a/utils'
|
import { createA2AClient } from '@/lib/a2a/utils'
|
||||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||||
import { validateExternalUrl } from '@/lib/core/security/input-validation'
|
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
@@ -40,18 +39,6 @@ export async function POST(request: NextRequest) {
|
|||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const validatedData = A2ASetPushNotificationSchema.parse(body)
|
const validatedData = A2ASetPushNotificationSchema.parse(body)
|
||||||
|
|
||||||
const urlValidation = validateExternalUrl(validatedData.webhookUrl, 'Webhook URL')
|
|
||||||
if (!urlValidation.isValid) {
|
|
||||||
logger.warn(`[${requestId}] Invalid webhook URL`, { error: urlValidation.error })
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
error: urlValidation.error,
|
|
||||||
},
|
|
||||||
{ status: 400 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`[${requestId}] A2A set push notification request`, {
|
logger.info(`[${requestId}] A2A set push notification request`, {
|
||||||
agentUrl: validatedData.agentUrl,
|
agentUrl: validatedData.agentUrl,
|
||||||
taskId: validatedData.taskId,
|
taskId: validatedData.taskId,
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ describe('Custom Tools API Routes', () => {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
vi.doMock('@/lib/auth/hybrid', () => ({
|
vi.doMock('@/lib/auth/hybrid', () => ({
|
||||||
checkSessionOrInternalAuth: vi.fn().mockResolvedValue({
|
checkHybridAuth: vi.fn().mockResolvedValue({
|
||||||
success: true,
|
success: true,
|
||||||
userId: 'user-123',
|
userId: 'user-123',
|
||||||
authType: 'session',
|
authType: 'session',
|
||||||
@@ -254,7 +254,7 @@ describe('Custom Tools API Routes', () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
vi.doMock('@/lib/auth/hybrid', () => ({
|
vi.doMock('@/lib/auth/hybrid', () => ({
|
||||||
checkSessionOrInternalAuth: vi.fn().mockResolvedValue({
|
checkHybridAuth: vi.fn().mockResolvedValue({
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Unauthorized',
|
error: 'Unauthorized',
|
||||||
}),
|
}),
|
||||||
@@ -304,7 +304,7 @@ describe('Custom Tools API Routes', () => {
|
|||||||
describe('POST /api/tools/custom', () => {
|
describe('POST /api/tools/custom', () => {
|
||||||
it('should reject unauthorized requests', async () => {
|
it('should reject unauthorized requests', async () => {
|
||||||
vi.doMock('@/lib/auth/hybrid', () => ({
|
vi.doMock('@/lib/auth/hybrid', () => ({
|
||||||
checkSessionOrInternalAuth: vi.fn().mockResolvedValue({
|
checkHybridAuth: vi.fn().mockResolvedValue({
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Unauthorized',
|
error: 'Unauthorized',
|
||||||
}),
|
}),
|
||||||
@@ -390,7 +390,7 @@ describe('Custom Tools API Routes', () => {
|
|||||||
|
|
||||||
it('should prevent unauthorized deletion of user-scoped tool', async () => {
|
it('should prevent unauthorized deletion of user-scoped tool', async () => {
|
||||||
vi.doMock('@/lib/auth/hybrid', () => ({
|
vi.doMock('@/lib/auth/hybrid', () => ({
|
||||||
checkSessionOrInternalAuth: vi.fn().mockResolvedValue({
|
checkHybridAuth: vi.fn().mockResolvedValue({
|
||||||
success: true,
|
success: true,
|
||||||
userId: 'user-456',
|
userId: 'user-456',
|
||||||
authType: 'session',
|
authType: 'session',
|
||||||
@@ -413,7 +413,7 @@ describe('Custom Tools API Routes', () => {
|
|||||||
|
|
||||||
it('should reject unauthorized requests', async () => {
|
it('should reject unauthorized requests', async () => {
|
||||||
vi.doMock('@/lib/auth/hybrid', () => ({
|
vi.doMock('@/lib/auth/hybrid', () => ({
|
||||||
checkSessionOrInternalAuth: vi.fn().mockResolvedValue({
|
checkHybridAuth: vi.fn().mockResolvedValue({
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Unauthorized',
|
error: 'Unauthorized',
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { createLogger } from '@sim/logger'
|
|||||||
import { and, desc, eq, isNull, or } from 'drizzle-orm'
|
import { and, desc, eq, isNull, or } from 'drizzle-orm'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
import { upsertCustomTools } from '@/lib/workflows/custom-tools/operations'
|
import { upsertCustomTools } from '@/lib/workflows/custom-tools/operations'
|
||||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||||
@@ -42,8 +42,8 @@ export async function GET(request: NextRequest) {
|
|||||||
const workflowId = searchParams.get('workflowId')
|
const workflowId = searchParams.get('workflowId')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Use session/internal auth to support session and internal JWT (no API key access)
|
// Use hybrid auth to support session, API key, and internal JWT
|
||||||
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
|
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||||
if (!authResult.success || !authResult.userId) {
|
if (!authResult.success || !authResult.userId) {
|
||||||
logger.warn(`[${requestId}] Unauthorized custom tools access attempt`)
|
logger.warn(`[${requestId}] Unauthorized custom tools access attempt`)
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
@@ -69,8 +69,8 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check workspace permissions
|
// Check workspace permissions
|
||||||
// For internal JWT with workflowId: checkSessionOrInternalAuth already resolved userId from workflow owner
|
// For internal JWT with workflowId: checkHybridAuth already resolved userId from workflow owner
|
||||||
// For session: verify user has access to the workspace
|
// For session/API key: verify user has access to the workspace
|
||||||
// For legacy (no workspaceId): skip workspace check, rely on userId match
|
// For legacy (no workspaceId): skip workspace check, rely on userId match
|
||||||
if (resolvedWorkspaceId && !(authResult.authType === 'internal_jwt' && workflowId)) {
|
if (resolvedWorkspaceId && !(authResult.authType === 'internal_jwt' && workflowId)) {
|
||||||
const userPermission = await getUserEntityPermissions(
|
const userPermission = await getUserEntityPermissions(
|
||||||
@@ -116,8 +116,8 @@ export async function POST(req: NextRequest) {
|
|||||||
const requestId = generateRequestId()
|
const requestId = generateRequestId()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Use session/internal auth (no API key access)
|
// Use hybrid auth (though this endpoint is only called from UI)
|
||||||
const authResult = await checkSessionOrInternalAuth(req, { requireWorkflowId: false })
|
const authResult = await checkHybridAuth(req, { requireWorkflowId: false })
|
||||||
if (!authResult.success || !authResult.userId) {
|
if (!authResult.success || !authResult.userId) {
|
||||||
logger.warn(`[${requestId}] Unauthorized custom tools update attempt`)
|
logger.warn(`[${requestId}] Unauthorized custom tools update attempt`)
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
@@ -193,8 +193,8 @@ export async function DELETE(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Use session/internal auth (no API key access)
|
// Use hybrid auth (though this endpoint is only called from UI)
|
||||||
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
|
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||||
if (!authResult.success || !authResult.userId) {
|
if (!authResult.success || !authResult.userId) {
|
||||||
logger.warn(`[${requestId}] Unauthorized custom tool deletion attempt`)
|
logger.warn(`[${requestId}] Unauthorized custom tool deletion attempt`)
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||||
import { validateNumericId } from '@/lib/core/security/input-validation'
|
import { validateNumericId } from '@/lib/core/security/input-validation'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
|
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
|
||||||
@@ -22,7 +22,7 @@ export async function POST(request: NextRequest) {
|
|||||||
const requestId = generateRequestId()
|
const requestId = generateRequestId()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||||
|
|
||||||
if (!authResult.success) {
|
if (!authResult.success) {
|
||||||
logger.warn(`[${requestId}] Unauthorized Discord send attempt: ${authResult.error}`)
|
logger.warn(`[${requestId}] Unauthorized Discord send attempt: ${authResult.error}`)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||||
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
|
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@ export async function POST(request: NextRequest) {
|
|||||||
const requestId = generateRequestId()
|
const requestId = generateRequestId()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||||
|
|
||||||
if (!authResult.success) {
|
if (!authResult.success) {
|
||||||
logger.warn(`[${requestId}] Unauthorized Gmail add label attempt: ${authResult.error}`)
|
logger.warn(`[${requestId}] Unauthorized Gmail add label attempt: ${authResult.error}`)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
@@ -19,7 +19,7 @@ export async function POST(request: NextRequest) {
|
|||||||
const requestId = generateRequestId()
|
const requestId = generateRequestId()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||||
|
|
||||||
if (!authResult.success) {
|
if (!authResult.success) {
|
||||||
logger.warn(`[${requestId}] Unauthorized Gmail archive attempt: ${authResult.error}`)
|
logger.warn(`[${requestId}] Unauthorized Gmail archive attempt: ${authResult.error}`)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
@@ -19,7 +19,7 @@ export async function POST(request: NextRequest) {
|
|||||||
const requestId = generateRequestId()
|
const requestId = generateRequestId()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||||
|
|
||||||
if (!authResult.success) {
|
if (!authResult.success) {
|
||||||
logger.warn(`[${requestId}] Unauthorized Gmail delete attempt: ${authResult.error}`)
|
logger.warn(`[${requestId}] Unauthorized Gmail delete attempt: ${authResult.error}`)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
|
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
|
||||||
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
|
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
|
||||||
@@ -35,7 +35,7 @@ export async function POST(request: NextRequest) {
|
|||||||
const requestId = generateRequestId()
|
const requestId = generateRequestId()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||||
|
|
||||||
if (!authResult.success) {
|
if (!authResult.success) {
|
||||||
logger.warn(`[${requestId}] Unauthorized Gmail draft attempt: ${authResult.error}`)
|
logger.warn(`[${requestId}] Unauthorized Gmail draft attempt: ${authResult.error}`)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
@@ -19,7 +19,7 @@ export async function POST(request: NextRequest) {
|
|||||||
const requestId = generateRequestId()
|
const requestId = generateRequestId()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||||
|
|
||||||
if (!authResult.success) {
|
if (!authResult.success) {
|
||||||
logger.warn(`[${requestId}] Unauthorized Gmail mark read attempt: ${authResult.error}`)
|
logger.warn(`[${requestId}] Unauthorized Gmail mark read attempt: ${authResult.error}`)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
@@ -19,7 +19,7 @@ export async function POST(request: NextRequest) {
|
|||||||
const requestId = generateRequestId()
|
const requestId = generateRequestId()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||||
|
|
||||||
if (!authResult.success) {
|
if (!authResult.success) {
|
||||||
logger.warn(`[${requestId}] Unauthorized Gmail mark unread attempt: ${authResult.error}`)
|
logger.warn(`[${requestId}] Unauthorized Gmail mark unread attempt: ${authResult.error}`)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
@@ -21,7 +21,7 @@ export async function POST(request: NextRequest) {
|
|||||||
const requestId = generateRequestId()
|
const requestId = generateRequestId()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||||
|
|
||||||
if (!authResult.success) {
|
if (!authResult.success) {
|
||||||
logger.warn(`[${requestId}] Unauthorized Gmail move attempt: ${authResult.error}`)
|
logger.warn(`[${requestId}] Unauthorized Gmail move attempt: ${authResult.error}`)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||||
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
|
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@ export async function POST(request: NextRequest) {
|
|||||||
const requestId = generateRequestId()
|
const requestId = generateRequestId()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||||
|
|
||||||
if (!authResult.success) {
|
if (!authResult.success) {
|
||||||
logger.warn(`[${requestId}] Unauthorized Gmail remove label attempt: ${authResult.error}`)
|
logger.warn(`[${requestId}] Unauthorized Gmail remove label attempt: ${authResult.error}`)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
|
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
|
||||||
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
|
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
|
||||||
@@ -35,7 +35,7 @@ export async function POST(request: NextRequest) {
|
|||||||
const requestId = generateRequestId()
|
const requestId = generateRequestId()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||||
|
|
||||||
if (!authResult.success) {
|
if (!authResult.success) {
|
||||||
logger.warn(`[${requestId}] Unauthorized Gmail send attempt: ${authResult.error}`)
|
logger.warn(`[${requestId}] Unauthorized Gmail send attempt: ${authResult.error}`)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
@@ -19,7 +19,7 @@ export async function POST(request: NextRequest) {
|
|||||||
const requestId = generateRequestId()
|
const requestId = generateRequestId()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||||
|
|
||||||
if (!authResult.success) {
|
if (!authResult.success) {
|
||||||
logger.warn(`[${requestId}] Unauthorized Gmail unarchive attempt: ${authResult.error}`)
|
logger.warn(`[${requestId}] Unauthorized Gmail unarchive attempt: ${authResult.error}`)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
import { processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils'
|
import { processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils'
|
||||||
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
|
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
|
||||||
@@ -56,7 +56,7 @@ export async function POST(request: NextRequest) {
|
|||||||
const requestId = generateRequestId()
|
const requestId = generateRequestId()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||||
|
|
||||||
if (!authResult.success) {
|
if (!authResult.success) {
|
||||||
logger.warn(`[${requestId}] Unauthorized Google Drive upload attempt: ${authResult.error}`)
|
logger.warn(`[${requestId}] Unauthorized Google Drive upload attempt: ${authResult.error}`)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger'
|
|||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { Resend } from 'resend'
|
import { Resend } from 'resend'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
@@ -22,7 +22,7 @@ export async function POST(request: NextRequest) {
|
|||||||
const requestId = generateRequestId()
|
const requestId = generateRequestId()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||||
|
|
||||||
if (!authResult.success) {
|
if (!authResult.success) {
|
||||||
logger.warn(`[${requestId}] Unauthorized mail send attempt: ${authResult.error}`)
|
logger.warn(`[${requestId}] Unauthorized mail send attempt: ${authResult.error}`)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
@@ -18,7 +18,7 @@ export async function POST(request: NextRequest) {
|
|||||||
const requestId = generateRequestId()
|
const requestId = generateRequestId()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||||
|
|
||||||
if (!authResult.success) {
|
if (!authResult.success) {
|
||||||
logger.warn(`[${requestId}] Unauthorized Teams chat delete attempt: ${authResult.error}`)
|
logger.warn(`[${requestId}] Unauthorized Teams chat delete attempt: ${authResult.error}`)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
|
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
|
||||||
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
|
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
|
||||||
@@ -23,7 +23,7 @@ export async function POST(request: NextRequest) {
|
|||||||
const requestId = generateRequestId()
|
const requestId = generateRequestId()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||||
|
|
||||||
if (!authResult.success) {
|
if (!authResult.success) {
|
||||||
logger.warn(`[${requestId}] Unauthorized Teams channel write attempt: ${authResult.error}`)
|
logger.warn(`[${requestId}] Unauthorized Teams channel write attempt: ${authResult.error}`)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
|
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
|
||||||
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
|
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
|
||||||
@@ -22,7 +22,7 @@ export async function POST(request: NextRequest) {
|
|||||||
const requestId = generateRequestId()
|
const requestId = generateRequestId()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||||
|
|
||||||
if (!authResult.success) {
|
if (!authResult.success) {
|
||||||
logger.warn(`[${requestId}] Unauthorized Teams chat write attempt: ${authResult.error}`)
|
logger.warn(`[${requestId}] Unauthorized Teams chat write attempt: ${authResult.error}`)
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||||
import { StorageService } from '@/lib/uploads'
|
import { StorageService } from '@/lib/uploads'
|
||||||
import {
|
import { extractStorageKey, inferContextFromKey } from '@/lib/uploads/utils/file-utils'
|
||||||
extractStorageKey,
|
|
||||||
inferContextFromKey,
|
|
||||||
isInternalFileUrl,
|
|
||||||
} from '@/lib/uploads/utils/file-utils'
|
|
||||||
import { verifyFileAccess } from '@/app/api/files/authorization'
|
import { verifyFileAccess } from '@/app/api/files/authorization'
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
@@ -30,7 +26,7 @@ export async function POST(request: NextRequest) {
|
|||||||
const requestId = generateRequestId()
|
const requestId = generateRequestId()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||||
|
|
||||||
if (!authResult.success || !authResult.userId) {
|
if (!authResult.success || !authResult.userId) {
|
||||||
logger.warn(`[${requestId}] Unauthorized Mistral parse attempt`, {
|
logger.warn(`[${requestId}] Unauthorized Mistral parse attempt`, {
|
||||||
@@ -51,13 +47,13 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
logger.info(`[${requestId}] Mistral parse request`, {
|
logger.info(`[${requestId}] Mistral parse request`, {
|
||||||
filePath: validatedData.filePath,
|
filePath: validatedData.filePath,
|
||||||
isWorkspaceFile: isInternalFileUrl(validatedData.filePath),
|
isWorkspaceFile: validatedData.filePath.includes('/api/files/serve/'),
|
||||||
userId,
|
userId,
|
||||||
})
|
})
|
||||||
|
|
||||||
let fileUrl = validatedData.filePath
|
let fileUrl = validatedData.filePath
|
||||||
|
|
||||||
if (isInternalFileUrl(validatedData.filePath)) {
|
if (validatedData.filePath?.includes('/api/files/serve/')) {
|
||||||
try {
|
try {
|
||||||
const storageKey = extractStorageKey(validatedData.filePath)
|
const storageKey = extractStorageKey(validatedData.filePath)
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
|
||||||
import { buildDeleteQuery, createMySQLConnection, executeQuery } from '@/app/api/tools/mysql/utils'
|
import { buildDeleteQuery, createMySQLConnection, executeQuery } from '@/app/api/tools/mysql/utils'
|
||||||
|
|
||||||
const logger = createLogger('MySQLDeleteAPI')
|
const logger = createLogger('MySQLDeleteAPI')
|
||||||
@@ -22,12 +21,6 @@ export async function POST(request: NextRequest) {
|
|||||||
const requestId = randomUUID().slice(0, 8)
|
const requestId = randomUUID().slice(0, 8)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const auth = await checkInternalAuth(request)
|
|
||||||
if (!auth.success || !auth.userId) {
|
|
||||||
logger.warn(`[${requestId}] Unauthorized MySQL delete attempt`)
|
|
||||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const params = DeleteSchema.parse(body)
|
const params = DeleteSchema.parse(body)
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
|
||||||
import { createMySQLConnection, executeQuery, validateQuery } from '@/app/api/tools/mysql/utils'
|
import { createMySQLConnection, executeQuery, validateQuery } from '@/app/api/tools/mysql/utils'
|
||||||
|
|
||||||
const logger = createLogger('MySQLExecuteAPI')
|
const logger = createLogger('MySQLExecuteAPI')
|
||||||
@@ -21,12 +20,6 @@ export async function POST(request: NextRequest) {
|
|||||||
const requestId = randomUUID().slice(0, 8)
|
const requestId = randomUUID().slice(0, 8)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const auth = await checkInternalAuth(request)
|
|
||||||
if (!auth.success || !auth.userId) {
|
|
||||||
logger.warn(`[${requestId}] Unauthorized MySQL execute attempt`)
|
|
||||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const params = ExecuteSchema.parse(body)
|
const params = ExecuteSchema.parse(body)
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
|
||||||
import { buildInsertQuery, createMySQLConnection, executeQuery } from '@/app/api/tools/mysql/utils'
|
import { buildInsertQuery, createMySQLConnection, executeQuery } from '@/app/api/tools/mysql/utils'
|
||||||
|
|
||||||
const logger = createLogger('MySQLInsertAPI')
|
const logger = createLogger('MySQLInsertAPI')
|
||||||
@@ -43,12 +42,6 @@ export async function POST(request: NextRequest) {
|
|||||||
const requestId = randomUUID().slice(0, 8)
|
const requestId = randomUUID().slice(0, 8)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const auth = await checkInternalAuth(request)
|
|
||||||
if (!auth.success || !auth.userId) {
|
|
||||||
logger.warn(`[${requestId}] Unauthorized MySQL insert attempt`)
|
|
||||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const params = InsertSchema.parse(body)
|
const params = InsertSchema.parse(body)
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
|
||||||
import { createMySQLConnection, executeIntrospect } from '@/app/api/tools/mysql/utils'
|
import { createMySQLConnection, executeIntrospect } from '@/app/api/tools/mysql/utils'
|
||||||
|
|
||||||
const logger = createLogger('MySQLIntrospectAPI')
|
const logger = createLogger('MySQLIntrospectAPI')
|
||||||
@@ -20,12 +19,6 @@ export async function POST(request: NextRequest) {
|
|||||||
const requestId = randomUUID().slice(0, 8)
|
const requestId = randomUUID().slice(0, 8)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const auth = await checkInternalAuth(request)
|
|
||||||
if (!auth.success || !auth.userId) {
|
|
||||||
logger.warn(`[${requestId}] Unauthorized MySQL introspect attempt`)
|
|
||||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const params = IntrospectSchema.parse(body)
|
const params = IntrospectSchema.parse(body)
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user