Compare commits
66 Commits
lakees/db
...
fix/codege
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12239a0803 | ||
|
|
c264481361 | ||
|
|
0e9bcdf1b2 | ||
|
|
e20ec7ae3c | ||
|
|
87dcd53b98 | ||
|
|
8aabf06f62 | ||
|
|
b45fc62e7b | ||
|
|
80f00479a3 | ||
|
|
c140e90559 | ||
|
|
d83c418111 | ||
|
|
be2a9ef0f8 | ||
|
|
1bf5ed4586 | ||
|
|
dc0ed842c4 | ||
|
|
1952b196a0 | ||
|
|
fa03d4d818 | ||
|
|
e14cebeec5 | ||
|
|
404d8c006e | ||
|
|
ac91d78834 | ||
|
|
6f0a093869 | ||
|
|
bcf6dc8828 | ||
|
|
841cb638fb | ||
|
|
c7db48e3a2 | ||
|
|
4d844651c2 | ||
|
|
9f916940b3 | ||
|
|
3bbf7f5d1d | ||
|
|
68683258c3 | ||
|
|
fc7f56e21b | ||
|
|
8429040921 | ||
|
|
8574e6c71f | ||
|
|
9c3e663cd8 | ||
|
|
48adaa00d8 | ||
|
|
211a7ac3a4 | ||
|
|
0f9b6ad1d2 | ||
|
|
12100e6881 | ||
|
|
23294683e1 | ||
|
|
b913cff46e | ||
|
|
428781ce7d | ||
|
|
f0ee67f3ed | ||
|
|
f44594c380 | ||
|
|
6464cfa7f2 | ||
|
|
7f4edc85ef | ||
|
|
efef91ece0 | ||
|
|
64efeaa2e6 | ||
|
|
9b72b52b33 | ||
|
|
1467862488 | ||
|
|
7f2262857c | ||
|
|
1b309b50e6 | ||
|
|
f765b83a26 | ||
|
|
aa99db6fdd | ||
|
|
748793e07d | ||
|
|
91da7e183a | ||
|
|
ab09a5ad23 | ||
|
|
fcd0240db6 | ||
|
|
4e4149792a | ||
|
|
9a8b591257 | ||
|
|
f3ae3f8442 | ||
|
|
66dfe2c6b2 | ||
|
|
376f7cb571 | ||
|
|
42159c23b9 | ||
|
|
2f0f246002 | ||
|
|
900d3ef9ea | ||
|
|
f3fcc28f89 | ||
|
|
7cfdf46724 | ||
|
|
d681451297 | ||
|
|
5987a6d060 | ||
|
|
e2ccefb2f4 |
@@ -44,7 +44,7 @@ services:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 4G
|
||||
memory: 1G
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
- DATABASE_URL=postgresql://postgres:postgres@db:5432/simstudio
|
||||
|
||||
5
.github/workflows/ci.yml
vendored
@@ -27,10 +27,11 @@ jobs:
|
||||
steps:
|
||||
- name: Extract version from commit message
|
||||
id: extract
|
||||
env:
|
||||
COMMIT_MSG: ${{ github.event.head_commit.message }}
|
||||
run: |
|
||||
COMMIT_MSG="${{ github.event.head_commit.message }}"
|
||||
# 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]}"
|
||||
echo "version=${VERSION}" >> $GITHUB_OUTPUT
|
||||
echo "is_release=true" >> $GITHUB_OUTPUT
|
||||
|
||||
@@ -119,6 +119,19 @@ aside#nd-sidebar {
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide TOC popover on tablet/medium screens (768px - 1279px) */
|
||||
/* Keeps it visible on mobile (<768px) for easy navigation */
|
||||
/* Desktop (>=1280px) already hides it via fumadocs xl:hidden */
|
||||
@media (min-width: 768px) and (max-width: 1279px) {
|
||||
#nd-docs-layout {
|
||||
--fd-toc-popover-height: 0px !important;
|
||||
}
|
||||
|
||||
[data-toc-popover] {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Desktop only: Apply custom navbar offset, sidebar width and margin offsets */
|
||||
/* On mobile, let fumadocs handle the layout natively */
|
||||
@media (min-width: 1024px) {
|
||||
|
||||
@@ -4696,26 +4696,6 @@ export function BedrockIcon(props: SVGProps<SVGSVGElement>) {
|
||||
)
|
||||
}
|
||||
|
||||
export function TableIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth={2}
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
{...props}
|
||||
>
|
||||
<rect width='18' height='18' x='3' y='3' rx='2' />
|
||||
<path d='M3 9h18' />
|
||||
<path d='M3 15h18' />
|
||||
<path d='M9 3v18' />
|
||||
<path d='M15 3v18' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
export function ReductoIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
|
||||
40
apps/docs/components/ui/action-media.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
'use client'
|
||||
|
||||
import { getAssetUrl } from '@/lib/utils'
|
||||
|
||||
interface ActionImageProps {
|
||||
src: string
|
||||
alt: string
|
||||
}
|
||||
|
||||
interface ActionVideoProps {
|
||||
src: string
|
||||
alt: string
|
||||
}
|
||||
|
||||
export function ActionImage({ src, alt }: ActionImageProps) {
|
||||
const resolvedSrc = getAssetUrl(src.startsWith('/') ? src.slice(1) : src)
|
||||
|
||||
return (
|
||||
<img
|
||||
src={resolvedSrc}
|
||||
alt={alt}
|
||||
className='inline-block w-full max-w-[200px] rounded border border-neutral-200 dark:border-neutral-700'
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function ActionVideo({ src, alt }: ActionVideoProps) {
|
||||
const resolvedSrc = getAssetUrl(src.startsWith('/') ? src.slice(1) : src)
|
||||
|
||||
return (
|
||||
<video
|
||||
src={resolvedSrc}
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
className='inline-block w-full max-w-[200px] rounded border border-neutral-200 dark:border-neutral-700'
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -108,7 +108,6 @@ import {
|
||||
StagehandIcon,
|
||||
StripeIcon,
|
||||
SupabaseIcon,
|
||||
TableIcon,
|
||||
TavilyIcon,
|
||||
TelegramIcon,
|
||||
TextractIcon,
|
||||
@@ -237,7 +236,6 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
stripe: StripeIcon,
|
||||
stt: STTIcon,
|
||||
supabase: SupabaseIcon,
|
||||
table: TableIcon,
|
||||
tavily: TavilyIcon,
|
||||
telegram: TelegramIcon,
|
||||
textract: TextractIcon,
|
||||
|
||||
@@ -10,12 +10,20 @@ Stellen Sie Sim auf Ihrer eigenen Infrastruktur mit Docker oder Kubernetes berei
|
||||
|
||||
## Anforderungen
|
||||
|
||||
| Ressource | Minimum | Empfohlen |
|
||||
|----------|---------|-------------|
|
||||
| CPU | 2 Kerne | 4+ Kerne |
|
||||
| RAM | 12 GB | 16+ GB |
|
||||
| Speicher | 20 GB SSD | 50+ GB SSD |
|
||||
| Docker | 20.10+ | Neueste Version |
|
||||
| Ressource | Klein | Standard | Produktion |
|
||||
|----------|-------|----------|------------|
|
||||
| CPU | 2 Kerne | 4 Kerne | 8+ Kerne |
|
||||
| RAM | 12 GB | 16 GB | 32+ GB |
|
||||
| Speicher | 20 GB SSD | 50 GB SSD | 100+ GB SSD |
|
||||
| Docker | 20.10+ | 20.10+ | Neueste Version |
|
||||
|
||||
**Klein**: Entwicklung, Tests, Einzelnutzer (1-5 Nutzer)
|
||||
**Standard**: Teams (5-50 Nutzer), moderate Arbeitslasten
|
||||
**Produktion**: Große Teams (50+ Nutzer), Hochverfügbarkeit, intensive Workflow-Ausführung
|
||||
|
||||
<Callout type="info">
|
||||
Die Ressourcenanforderungen werden durch Workflow-Ausführung (isolated-vm Sandboxing), Dateiverarbeitung (In-Memory-Dokumentenparsing) und Vektoroperationen (pgvector) bestimmt. Arbeitsspeicher ist typischerweise der limitierende Faktor, nicht CPU. Produktionsdaten zeigen, dass die Hauptanwendung durchschnittlich 4-8 GB und bei hoher Last bis zu 12 GB benötigt.
|
||||
</Callout>
|
||||
|
||||
## Schnellstart
|
||||
|
||||
|
||||
@@ -124,11 +124,44 @@ Choose between four types of loops:
|
||||
3. Drag other blocks inside the loop container
|
||||
4. Connect the blocks as needed
|
||||
|
||||
### Accessing Results
|
||||
### Referencing Loop Data
|
||||
|
||||
After a loop completes, you can access aggregated results:
|
||||
There's an important distinction between referencing loop data from **inside** vs **outside** the loop:
|
||||
|
||||
- **`<loop.results>`**: Array of results from all loop iterations
|
||||
<Tabs items={['Inside the Loop', 'Outside the Loop']}>
|
||||
<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
|
||||
|
||||
@@ -184,28 +217,29 @@ Variables (i=0) → Loop (While i<10) → Agent (Process) → Variables (i++)
|
||||
</ul>
|
||||
</Tab>
|
||||
<Tab>
|
||||
Available **inside** the loop only:
|
||||
<ul className="list-disc space-y-2 pl-6">
|
||||
<li>
|
||||
<strong>loop.currentItem</strong>: Current item being processed
|
||||
<strong>{"<loop.index>"}</strong>: Current iteration number (0-based)
|
||||
</li>
|
||||
<li>
|
||||
<strong>loop.index</strong>: Current iteration number (0-based)
|
||||
<strong>{"<loop.currentItem>"}</strong>: Current item being processed (forEach only)
|
||||
</li>
|
||||
<li>
|
||||
<strong>loop.items</strong>: Full collection (forEach loops)
|
||||
<strong>{"<loop.items>"}</strong>: Full collection (forEach only)
|
||||
</li>
|
||||
</ul>
|
||||
</Tab>
|
||||
<Tab>
|
||||
<ul className="list-disc space-y-2 pl-6">
|
||||
<li>
|
||||
<strong>loop.results</strong>: Array of all iteration results
|
||||
<strong>{"<blockname.results>"}</strong>: Array of all iteration results (accessed via block name)
|
||||
</li>
|
||||
<li>
|
||||
<strong>Structure</strong>: Results maintain iteration order
|
||||
</li>
|
||||
<li>
|
||||
<strong>Access</strong>: Available in blocks after the loop
|
||||
<strong>Access</strong>: Available in blocks after the loop completes
|
||||
</li>
|
||||
</ul>
|
||||
</Tab>
|
||||
|
||||
@@ -76,11 +76,44 @@ Choose between two types of parallel execution:
|
||||
3. Drag a single block inside the parallel container
|
||||
4. Connect the block as needed
|
||||
|
||||
### Accessing Results
|
||||
### Referencing Parallel Data
|
||||
|
||||
After a parallel block completes, you can access aggregated results:
|
||||
There's an important distinction between referencing parallel data from **inside** vs **outside** the parallel block:
|
||||
|
||||
- **`<parallel.results>`**: Array of results from all parallel instances
|
||||
<Tabs items={['Inside the Parallel', 'Outside the Parallel']}>
|
||||
<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
|
||||
|
||||
@@ -98,11 +131,11 @@ Parallel (["gpt-4o", "claude-3.7-sonnet", "gemini-2.5-pro"]) → Agent → Evalu
|
||||
|
||||
### Result Aggregation
|
||||
|
||||
Results from all parallel instances are automatically collected:
|
||||
Results from all parallel instances are automatically collected and accessible via the block name:
|
||||
|
||||
```javascript
|
||||
// In a Function block after the parallel
|
||||
const allResults = input.parallel.results;
|
||||
// In a Function block after a parallel named "Process Tasks"
|
||||
const allResults = <processtasks.results>;
|
||||
// Returns: [result1, result2, result3, ...]
|
||||
```
|
||||
|
||||
@@ -158,25 +191,26 @@ Understanding when to use each:
|
||||
</ul>
|
||||
</Tab>
|
||||
<Tab>
|
||||
Available **inside** the parallel only:
|
||||
<ul className="list-disc space-y-2 pl-6">
|
||||
<li>
|
||||
<strong>parallel.currentItem</strong>: Item for this instance
|
||||
<strong>{"<parallel.index>"}</strong>: Instance number (0-based)
|
||||
</li>
|
||||
<li>
|
||||
<strong>parallel.index</strong>: Instance number (0-based)
|
||||
<strong>{"<parallel.currentItem>"}</strong>: Item for this instance (collection-based only)
|
||||
</li>
|
||||
<li>
|
||||
<strong>parallel.items</strong>: Full collection (collection-based)
|
||||
<strong>{"<parallel.items>"}</strong>: Full collection (collection-based only)
|
||||
</li>
|
||||
</ul>
|
||||
</Tab>
|
||||
<Tab>
|
||||
<ul className="list-disc space-y-2 pl-6">
|
||||
<li>
|
||||
<strong>parallel.results</strong>: Array of all instance results
|
||||
<strong>{"<blockname.results>"}</strong>: Array of all instance results (accessed via block name)
|
||||
</li>
|
||||
<li>
|
||||
<strong>Access</strong>: Available in blocks after the parallel
|
||||
<strong>Access</strong>: Available in blocks after the parallel completes
|
||||
</li>
|
||||
</ul>
|
||||
</Tab>
|
||||
|
||||
@@ -5,45 +5,25 @@ title: Copilot
|
||||
import { Callout } from 'fumadocs-ui/components/callout'
|
||||
import { Card, Cards } from 'fumadocs-ui/components/card'
|
||||
import { Image } from '@/components/ui/image'
|
||||
import { MessageCircle, Package, Zap, Infinity as InfinityIcon, Brain, BrainCircuit } from 'lucide-react'
|
||||
import { MessageCircle, Hammer, Zap, Globe, Paperclip, History, RotateCcw, Brain } from 'lucide-react'
|
||||
|
||||
Copilot is your in-editor assistant that helps you build and edit workflows with Sim Copilot, as well as understand and improve them. It can:
|
||||
Copilot is your in-editor assistant that helps you build and edit workflows. It can:
|
||||
|
||||
- **Explain**: Answer questions about Sim and your current workflow
|
||||
- **Guide**: Suggest edits and best practices
|
||||
- **Edit**: Make changes to blocks, connections, and settings when you approve
|
||||
- **Build**: Add blocks, wire connections, and configure settings
|
||||
- **Debug**: Analyze execution issues and optimize performance
|
||||
|
||||
<Callout type="info">
|
||||
Copilot is a Sim-managed service. For self-hosted deployments, generate a Copilot API key in the hosted app (sim.ai → Settings → Copilot)
|
||||
Copilot is a Sim-managed service. For self-hosted deployments:
|
||||
1. Go to [sim.ai](https://sim.ai) → Settings → Copilot and generate a Copilot API key
|
||||
2. Set `COPILOT_API_KEY` in your self-hosted environment to that value
|
||||
2. Set `COPILOT_API_KEY` in your self-hosted environment
|
||||
</Callout>
|
||||
|
||||
## Context Menu (@)
|
||||
|
||||
Use the `@` symbol to reference various resources and give Copilot more context about your workspace:
|
||||
|
||||
<Image
|
||||
src="/static/copilot/copilot-menu.png"
|
||||
alt="Copilot context menu showing available reference options"
|
||||
width={600}
|
||||
height={400}
|
||||
/>
|
||||
|
||||
The `@` menu provides access to:
|
||||
- **Chats**: Reference previous copilot conversations
|
||||
- **All workflows**: Reference any workflow in your workspace
|
||||
- **Workflow Blocks**: Reference specific blocks from workflows
|
||||
- **Blocks**: Reference block types and templates
|
||||
- **Knowledge**: Reference your uploaded documents and knowledgebase
|
||||
- **Docs**: Reference Sim documentation
|
||||
- **Templates**: Reference workflow templates
|
||||
- **Logs**: Reference execution logs and results
|
||||
|
||||
This contextual information helps Copilot provide more accurate and relevant assistance for your specific use case.
|
||||
|
||||
## Modes
|
||||
|
||||
Switch between modes using the mode selector at the bottom of the input area.
|
||||
|
||||
<Cards>
|
||||
<Card
|
||||
title={
|
||||
@@ -60,113 +40,153 @@ This contextual information helps Copilot provide more accurate and relevant ass
|
||||
<Card
|
||||
title={
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Package className="h-4 w-4 text-muted-foreground" />
|
||||
Agent
|
||||
<Hammer className="h-4 w-4 text-muted-foreground" />
|
||||
Build
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<div className="m-0 text-sm">
|
||||
Build-and-edit mode. Copilot proposes specific edits (add blocks, wire variables, tweak settings) and applies them when you approve.
|
||||
Workflow building mode. Copilot can add blocks, wire connections, edit configurations, and debug issues.
|
||||
</div>
|
||||
</Card>
|
||||
</Cards>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Image
|
||||
src="/static/copilot/copilot-mode.png"
|
||||
alt="Copilot mode selection interface"
|
||||
width={600}
|
||||
height={400}
|
||||
className="my-6"
|
||||
/>
|
||||
</div>
|
||||
## Models
|
||||
|
||||
## Depth Levels
|
||||
Select your preferred AI model using the model selector at the bottom right of the input area.
|
||||
|
||||
<Cards>
|
||||
<Card
|
||||
title={
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Zap className="h-4 w-4 text-muted-foreground" />
|
||||
Fast
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<div className="m-0 text-sm">Quickest and cheapest. Best for small edits, simple workflows, and minor tweaks.</div>
|
||||
</Card>
|
||||
<Card
|
||||
title={
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<InfinityIcon className="h-4 w-4 text-muted-foreground" />
|
||||
Auto
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<div className="m-0 text-sm">Balanced speed and reasoning. Recommended default for most tasks.</div>
|
||||
</Card>
|
||||
<Card
|
||||
title={
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Brain className="h-4 w-4 text-muted-foreground" />
|
||||
Advanced
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<div className="m-0 text-sm">More reasoning for larger workflows and complex edits while staying performant.</div>
|
||||
</Card>
|
||||
<Card
|
||||
title={
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<BrainCircuit className="h-4 w-4 text-muted-foreground" />
|
||||
Behemoth
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<div className="m-0 text-sm">Maximum reasoning for deep planning, debugging, and complex architectural changes.</div>
|
||||
</Card>
|
||||
</Cards>
|
||||
**Available Models:**
|
||||
- Claude 4.5 Opus, Sonnet (default), Haiku
|
||||
- GPT 5.2 Codex, Pro
|
||||
- Gemini 3 Pro
|
||||
|
||||
### Mode Selection Interface
|
||||
Choose based on your needs: faster models for simple tasks, more capable models for complex workflows.
|
||||
|
||||
You can easily switch between different reasoning modes using the mode selector in the Copilot interface:
|
||||
## Context Menu (@)
|
||||
|
||||
<Image
|
||||
src="/static/copilot/copilot-models.png"
|
||||
alt="Copilot mode selection showing Advanced mode with MAX toggle"
|
||||
width={600}
|
||||
height={300}
|
||||
/>
|
||||
Use the `@` symbol to reference resources and give Copilot more context:
|
||||
|
||||
The interface allows you to:
|
||||
- **Select reasoning level**: Choose from Fast, Auto, Advanced, or Behemoth
|
||||
- **Enable MAX mode**: Toggle for maximum reasoning capabilities when you need the most thorough analysis
|
||||
- **See mode descriptions**: Understand what each mode is optimized for
|
||||
| Reference | Description |
|
||||
|-----------|-------------|
|
||||
| **Chats** | Previous copilot conversations |
|
||||
| **Workflows** | Any workflow in your workspace |
|
||||
| **Workflow Blocks** | Blocks in the current workflow |
|
||||
| **Blocks** | Block types and templates |
|
||||
| **Knowledge** | Uploaded documents and knowledge bases |
|
||||
| **Docs** | Sim documentation |
|
||||
| **Templates** | Workflow templates |
|
||||
| **Logs** | Execution logs and results |
|
||||
|
||||
Choose your mode based on the complexity of your task - use Fast for simple questions and Behemoth for complex architectural changes.
|
||||
Type `@` in the input field to open the context menu, then search or browse to find what you need.
|
||||
|
||||
## Billing and Cost Calculation
|
||||
## Slash Commands (/)
|
||||
|
||||
### How Costs Are Calculated
|
||||
Use slash commands for quick actions:
|
||||
|
||||
Copilot usage is billed per token from the underlying LLM:
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/fast` | Fast mode execution |
|
||||
| `/research` | Research and exploration mode |
|
||||
| `/actions` | Execute agent actions |
|
||||
|
||||
- **Input tokens**: billed at the provider's base rate (**at-cost**)
|
||||
- **Output tokens**: billed at **1.5×** the provider's base output rate
|
||||
**Web Commands:**
|
||||
|
||||
```javascript
|
||||
copilotCost = (inputTokens × inputPrice + outputTokens × (outputPrice × 1.5)) / 1,000,000
|
||||
```
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/search` | Search the web |
|
||||
| `/read` | Read a specific URL |
|
||||
| `/scrape` | Scrape web page content |
|
||||
| `/crawl` | Crawl multiple pages |
|
||||
|
||||
| Component | Rate Applied |
|
||||
|----------|----------------------|
|
||||
| Input | inputPrice |
|
||||
| Output | outputPrice × 1.5 |
|
||||
Type `/` in the input field to see available commands.
|
||||
|
||||
<Callout type="warning">
|
||||
Pricing shown reflects rates as of September 4, 2025. Check provider documentation for current pricing.
|
||||
</Callout>
|
||||
## Chat Management
|
||||
|
||||
### Starting a New Chat
|
||||
|
||||
Click the **+** button in the Copilot header to start a fresh conversation.
|
||||
|
||||
### Chat History
|
||||
|
||||
Click **History** to view previous conversations grouped by date. You can:
|
||||
- Click a chat to resume it
|
||||
- Delete chats you no longer need
|
||||
|
||||
### Editing Messages
|
||||
|
||||
Hover over any of your messages and click **Edit** to modify and resend it. This is useful for refining your prompts.
|
||||
|
||||
### Message Queue
|
||||
|
||||
If you send a message while Copilot is still responding, it gets queued. You can:
|
||||
- View queued messages in the expandable queue panel
|
||||
- Send a queued message immediately (aborts current response)
|
||||
- Remove messages from the queue
|
||||
|
||||
## File Attachments
|
||||
|
||||
Click the attachment icon to upload files with your message. Supported file types include:
|
||||
- Images (preview thumbnails shown)
|
||||
- PDFs
|
||||
- Text files, JSON, XML
|
||||
- Other document formats
|
||||
|
||||
Files are displayed as clickable thumbnails that open in a new tab.
|
||||
|
||||
## Checkpoints & Changes
|
||||
|
||||
When Copilot makes changes to your workflow, it saves checkpoints so you can revert if needed.
|
||||
|
||||
### Viewing Checkpoints
|
||||
|
||||
Hover over a Copilot message and click the checkpoints icon to see saved workflow states for that message.
|
||||
|
||||
### Reverting Changes
|
||||
|
||||
Click **Revert** on any checkpoint to restore your workflow to that state. A confirmation dialog will warn that this action cannot be undone.
|
||||
|
||||
### Accepting Changes
|
||||
|
||||
When Copilot proposes changes, you can:
|
||||
- **Accept**: Apply the proposed changes (`Mod+Shift+Enter`)
|
||||
- **Reject**: Dismiss the changes and keep your current workflow
|
||||
|
||||
## Thinking Blocks
|
||||
|
||||
For complex requests, Copilot may show its reasoning process in expandable thinking blocks:
|
||||
|
||||
- Blocks auto-expand while Copilot is thinking
|
||||
- Click to manually expand/collapse
|
||||
- Shows duration of the thinking process
|
||||
- Helps you understand how Copilot arrived at its solution
|
||||
|
||||
## Options Selection
|
||||
|
||||
When Copilot presents multiple options, you can select using:
|
||||
|
||||
| Control | Action |
|
||||
|---------|--------|
|
||||
| **1-9** | Select option by number |
|
||||
| **Arrow Up/Down** | Navigate between options |
|
||||
| **Enter** | Select highlighted option |
|
||||
|
||||
Selected options are highlighted; unselected options appear struck through.
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
| Shortcut | Action |
|
||||
|----------|--------|
|
||||
| `@` | Open context menu |
|
||||
| `/` | Open slash commands |
|
||||
| `Arrow Up/Down` | Navigate menu items |
|
||||
| `Enter` | Select menu item |
|
||||
| `Esc` | Close menus |
|
||||
| `Mod+Shift+Enter` | Accept Copilot changes |
|
||||
|
||||
## Usage Limits
|
||||
|
||||
Copilot usage is billed per token from the underlying LLM. If you reach your usage limit, Copilot will prompt you to increase your limit. You can add usage in increments ($50, $100) from your current base.
|
||||
|
||||
<Callout type="info">
|
||||
Model prices are per million tokens. The calculation divides by 1,000,000 to get the actual cost. See <a href="/execution/costs">the Cost Calculation page</a> for background and examples.
|
||||
See the [Cost Calculation page](/execution/costs) for billing details.
|
||||
</Callout>
|
||||
|
||||
|
||||
@@ -34,6 +34,8 @@ Speed up your workflow building with these keyboard shortcuts and mouse controls
|
||||
| `Mod` + `V` | Paste blocks |
|
||||
| `Delete` or `Backspace` | Delete selected blocks or edges |
|
||||
| `Shift` + `L` | Auto-layout canvas |
|
||||
| `Mod` + `Shift` + `F` | Fit to view |
|
||||
| `Mod` + `Shift` + `Enter` | Accept Copilot changes |
|
||||
|
||||
## Panel Navigation
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"pages": [
|
||||
"./introduction/index",
|
||||
"./getting-started/index",
|
||||
"./quick-reference/index",
|
||||
"triggers",
|
||||
"blocks",
|
||||
"tools",
|
||||
|
||||
375
apps/docs/content/docs/en/quick-reference/index.mdx
Normal file
@@ -0,0 +1,375 @@
|
||||
---
|
||||
title: Quick Reference
|
||||
description: Essential actions for navigating and using the Sim workflow editor
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout'
|
||||
import { ActionImage, ActionVideo } from '@/components/ui/action-media'
|
||||
|
||||
A quick lookup for everyday actions in the Sim workflow editor. For keyboard shortcuts, see [Keyboard Shortcuts](/keyboard-shortcuts).
|
||||
|
||||
<Callout type="info">
|
||||
**Mod** refers to `Cmd` on macOS and `Ctrl` on Windows/Linux.
|
||||
</Callout>
|
||||
|
||||
## Workspaces
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Action</th><th>How</th><th>Preview</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Create a workspace</td>
|
||||
<td>Click workspace dropdown → **New Workspace**</td>
|
||||
<td><ActionVideo src="/static/quick-reference/create-workspace.mp4" alt="Create workspace" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Switch workspaces</td>
|
||||
<td>Click workspace dropdown → Select workspace</td>
|
||||
<td><ActionVideo src="/static/quick-reference/switch-workspace.mp4" alt="Switch workspaces" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Invite team members</td>
|
||||
<td>Sidebar → **Invite**</td>
|
||||
<td><ActionVideo src="/static/quick-reference/invite.mp4" alt="Invite team members" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Rename a workspace</td>
|
||||
<td>Right-click workspace → **Rename**</td>
|
||||
<td rowSpan={4}><ActionImage src="/static/quick-reference/workspace-context-menu.png" alt="Workspace context menu" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Duplicate a workspace</td>
|
||||
<td>Right-click workspace → **Duplicate**</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Export a workspace</td>
|
||||
<td>Right-click workspace → **Export**</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Delete a workspace</td>
|
||||
<td>Right-click workspace → **Delete**</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
## Workflows
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Action</th><th>How</th><th>Preview</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Create a workflow</td>
|
||||
<td>Click **+** button in sidebar</td>
|
||||
<td><ActionImage src="/static/quick-reference/create-workflow.png" alt="Create workflow" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Reorder / move workflows</td>
|
||||
<td>Drag workflow up/down or onto a folder</td>
|
||||
<td><ActionVideo src="/static/quick-reference/reordering.mp4" alt="Reorder workflows" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Import a workflow</td>
|
||||
<td>Click import button in sidebar → Select file</td>
|
||||
<td><ActionImage src="/static/quick-reference/import-workflow.png" alt="Import workflow" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Multi-select workflows</td>
|
||||
<td>`Mod+Click` or `Shift+Click` workflows in sidebar</td>
|
||||
<td><ActionVideo src="/static/quick-reference/multiselect.mp4" alt="Multi-select workflows" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Open in new tab</td>
|
||||
<td>Right-click workflow → **Open in New Tab**</td>
|
||||
<td rowSpan={6}><ActionImage src="/static/quick-reference/workflow-context-menu.png" alt="Workflow context menu" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Rename a workflow</td>
|
||||
<td>Right-click workflow → **Rename**</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Assign workflow color</td>
|
||||
<td>Right-click workflow → **Change Color**</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Duplicate a workflow</td>
|
||||
<td>Right-click workflow → **Duplicate**</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Export a workflow</td>
|
||||
<td>Right-click workflow → **Export**</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Delete a workflow</td>
|
||||
<td>Right-click workflow → **Delete**</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Rename a folder</td>
|
||||
<td>Right-click folder → **Rename**</td>
|
||||
<td rowSpan={6}><ActionImage src="/static/quick-reference/folder-context-menu.png" alt="Folder context menu" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Create workflow in folder</td>
|
||||
<td>Right-click folder → **Create workflow**</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Create folder in folder</td>
|
||||
<td>Right-click folder → **Create folder**</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Duplicate a folder</td>
|
||||
<td>Right-click folder → **Duplicate**</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Export a folder</td>
|
||||
<td>Right-click folder → **Export**</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Delete a folder</td>
|
||||
<td>Right-click folder → **Delete**</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
## Blocks
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Action</th><th>How</th><th>Preview</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Add a block</td>
|
||||
<td>Drag from Toolbar panel, or right-click canvas → **Add Block**</td>
|
||||
<td><ActionVideo src="/static/quick-reference/add-block.mp4" alt="Add a block" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Multi-select blocks</td>
|
||||
<td>`Mod+Click` additional blocks, or shift-drag to draw selection box</td>
|
||||
<td><ActionVideo src="/static/quick-reference/multiselect-blocks.mp4" alt="Multi-select blocks" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Copy blocks</td>
|
||||
<td>`Mod+C` with blocks selected</td>
|
||||
<td rowSpan={2}><ActionVideo src="/static/quick-reference/copy-paste.mp4" alt="Copy and paste blocks" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Paste blocks</td>
|
||||
<td>`Mod+V` to paste copied blocks</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Duplicate blocks</td>
|
||||
<td>Right-click → **Duplicate**</td>
|
||||
<td><ActionVideo src="/static/quick-reference/duplicate-block.mp4" alt="Duplicate blocks" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Delete blocks</td>
|
||||
<td>`Delete` or `Backspace` key, or right-click → **Delete**</td>
|
||||
<td><ActionImage src="/static/quick-reference/delete-block.png" alt="Delete block" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Rename a block</td>
|
||||
<td>Click block name in header, or edit in the Editor panel</td>
|
||||
<td><ActionVideo src="/static/quick-reference/rename-block.mp4" alt="Rename a block" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Enable/Disable a block</td>
|
||||
<td>Right-click → **Enable/Disable**</td>
|
||||
<td><ActionImage src="/static/quick-reference/disable-block.png" alt="Disable block" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Toggle handle orientation</td>
|
||||
<td>Right-click → **Toggle Handles**</td>
|
||||
<td><ActionVideo src="/static/quick-reference/toggle-handles.mp4" alt="Toggle handle orientation" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Configure a block</td>
|
||||
<td>Select block → use Editor panel on right</td>
|
||||
<td><ActionVideo src="/static/quick-reference/configure-block.mp4" alt="Configure a block" /></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
## Connections
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Action</th><th>How</th><th>Preview</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Create a connection</td>
|
||||
<td>Drag from output handle to input handle</td>
|
||||
<td><ActionVideo src="/static/quick-reference/connect-blocks.mp4" alt="Connect blocks" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Delete a connection</td>
|
||||
<td>Click edge to select → `Delete` key</td>
|
||||
<td><ActionVideo src="/static/quick-reference/delete-connection.mp4" alt="Delete connection" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Use output in another block</td>
|
||||
<td>Drag connection tag into input field</td>
|
||||
<td><ActionVideo src="/static/quick-reference/connection-tag.mp4" alt="Use connection tag" /></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
## Panels & Views
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Action</th><th>How</th><th>Preview</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Search toolbar</td>
|
||||
<td>`Mod+F`</td>
|
||||
<td><ActionVideo src="/static/quick-reference/search-toolbar.mp4" alt="Search toolbar" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Search everything</td>
|
||||
<td>`Mod+K`</td>
|
||||
<td><ActionImage src="/static/quick-reference/search-everything.png" alt="Search everything" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Toggle manual mode</td>
|
||||
<td>Click toggle button to switch between manual and selector</td>
|
||||
<td><ActionImage src="/static/quick-reference/toggle-manual-mode.png" alt="Toggle manual mode" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Collapse/expand sidebar</td>
|
||||
<td>Click collapse button on sidebar</td>
|
||||
<td><ActionVideo src="/static/quick-reference/collapse-sidebar.mp4" alt="Collapse sidebar" /></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
## Running & Testing
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Action</th><th>How</th><th>Preview</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Run workflow</td>
|
||||
<td>Click Run Workflow button or `Mod+Enter`</td>
|
||||
<td><ActionImage src="/static/quick-reference/run-workflow.png" alt="Run workflow" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Stop workflow</td>
|
||||
<td>Click Stop button or `Mod+Enter` while running</td>
|
||||
<td><ActionImage src="/static/quick-reference/stop-workflow.png" alt="Stop workflow" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Test with chat</td>
|
||||
<td>Use Chat panel on the right side</td>
|
||||
<td><ActionImage src="/static/quick-reference/test-chat.png" alt="Test with chat" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Select output to view</td>
|
||||
<td>Click dropdown in Chat panel → Select block output</td>
|
||||
<td><ActionImage src="/static/quick-reference/output-select.png" alt="Select output to view" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Clear chat history</td>
|
||||
<td>Click clear button in Chat panel</td>
|
||||
<td><ActionImage src="/static/quick-reference/clear-chat.png" alt="Clear chat history" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>View execution logs</td>
|
||||
<td>Open terminal panel at bottom, or `Mod+L`</td>
|
||||
<td><ActionImage src="/static/quick-reference/terminal.png" alt="Execution logs terminal" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Filter logs by block or status</td>
|
||||
<td>Click block filter in terminal or right-click log entry → **Filter by Block** or **Filter by Status**</td>
|
||||
<td><ActionImage src="/static/quick-reference/filter-block.png" alt="Filter logs by block" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Search logs</td>
|
||||
<td>Use search field in terminal or right-click log entry → **Search**</td>
|
||||
<td><ActionImage src="/static/quick-reference/terminal-search.png" alt="Search logs" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Copy log entry</td>
|
||||
<td>Clipboard Icon or Right-click log entry → **Copy**</td>
|
||||
<td><ActionImage src="/static/quick-reference/copy-log.png" alt="Copy log entry" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Clear terminal</td>
|
||||
<td>Trash icon or `Mod+D`</td>
|
||||
<td><ActionImage src="/static/quick-reference/clear-terminal.png" alt="Clear terminal" /></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
## Deployment
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Action</th><th>How</th><th>Preview</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Deploy a workflow</td>
|
||||
<td>Click **Deploy** button in panel</td>
|
||||
<td><ActionImage src="/static/quick-reference/deploy.png" alt="Deploy workflow" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Update deployment</td>
|
||||
<td>Click **Update** when changes are detected</td>
|
||||
<td><ActionImage src="/static/quick-reference/update-deployment.png" alt="Update deployment" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>View deployment status</td>
|
||||
<td>Check status indicator (Live/Update/Deploy) in Deploy tab</td>
|
||||
<td><ActionImage src="/static/quick-reference/view-deployment.png" alt="View deployment status" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Revert deployment</td>
|
||||
<td>Access previous versions in Deploy tab → **Promote to live**</td>
|
||||
<td><ActionImage src="/static/quick-reference/promote-deployment.png" alt="Promote deployment to live" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Copy API endpoint</td>
|
||||
<td>Deploy tab → Copy API endpoint URL</td>
|
||||
<td><ActionImage src="/static/quick-reference/copy-api.png" alt="Copy API endpoint" /></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
## Variables
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Action</th><th>How</th><th>Preview</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Add / Edit / Delete workflow variable</td>
|
||||
<td>Panel -> Variables -> **Add Variable**, click to edit, or delete icon</td>
|
||||
<td><ActionImage src="/static/quick-reference/variables.png" alt="Variables panel" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Add environment variable</td>
|
||||
<td>Settings → **Environment Variables** → **Add**</td>
|
||||
<td><ActionImage src="/static/quick-reference/add-env-variable.png" alt="Add environment variable" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Reference a workflow variable</td>
|
||||
<td>Use `<blockName.itemName>` syntax in block inputs</td>
|
||||
<td><ActionImage src="/static/quick-reference/variable-reference.png" alt="Reference workflow variable" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Reference an environment variable</td>
|
||||
<td>Use `{{ENV_VAR}}` syntax in block inputs</td>
|
||||
<td><ActionImage src="/static/quick-reference/env-variable-reference.png" alt="Reference environment variable" /></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -16,12 +16,20 @@ Deploy Sim on your own infrastructure with Docker or Kubernetes.
|
||||
|
||||
## Requirements
|
||||
|
||||
| Resource | Minimum | Recommended |
|
||||
|----------|---------|-------------|
|
||||
| CPU | 2 cores | 4+ cores |
|
||||
| RAM | 12 GB | 16+ GB |
|
||||
| Storage | 20 GB SSD | 50+ GB SSD |
|
||||
| Docker | 20.10+ | Latest |
|
||||
| Resource | Small | Standard | Production |
|
||||
|----------|-------|----------|------------|
|
||||
| CPU | 2 cores | 4 cores | 8+ cores |
|
||||
| RAM | 12 GB | 16 GB | 32+ GB |
|
||||
| Storage | 20 GB SSD | 50 GB SSD | 100+ GB SSD |
|
||||
| Docker | 20.10+ | 20.10+ | Latest |
|
||||
|
||||
**Small**: Development, testing, single user (1-5 users)
|
||||
**Standard**: Teams (5-50 users), moderate workloads
|
||||
**Production**: Large teams (50+ users), high availability, heavy workflow execution
|
||||
|
||||
<Callout type="info">
|
||||
Resource requirements are driven by workflow execution (isolated-vm sandboxing), file processing (in-memory document parsing), and vector operations (pgvector). Memory is typically the constraining factor rather than CPU. Production telemetry shows the main app uses 4-8 GB average with peaks up to 12 GB under heavy load.
|
||||
</Callout>
|
||||
|
||||
## Quick Start
|
||||
|
||||
|
||||
@@ -104,7 +104,6 @@
|
||||
"stripe",
|
||||
"stt",
|
||||
"supabase",
|
||||
"table",
|
||||
"tavily",
|
||||
"telegram",
|
||||
"textract",
|
||||
|
||||
@@ -1,351 +0,0 @@
|
||||
---
|
||||
title: Table
|
||||
description: User-defined data tables for storing and querying structured data
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="table"
|
||||
color="#10B981"
|
||||
/>
|
||||
|
||||
Tables allow you to create and manage custom data tables directly within Sim. Store, query, and manipulate structured data within your workflows without needing external database integrations.
|
||||
|
||||
**Why Use Tables?**
|
||||
- **No external setup**: Create tables instantly without configuring external databases
|
||||
- **Workflow-native**: Data persists across workflow executions and is accessible from any workflow in your workspace
|
||||
- **Flexible schema**: Define columns with types (string, number, boolean, date, json) and constraints (required, unique)
|
||||
- **Powerful querying**: Filter, sort, and paginate data using MongoDB-style operators
|
||||
- **Agent-friendly**: Tables can be used as tools by AI agents for dynamic data storage and retrieval
|
||||
|
||||
**Key Features:**
|
||||
- Create tables with custom schemas
|
||||
- Insert, update, upsert, and delete rows
|
||||
- Query with filters and sorting
|
||||
- Batch operations for bulk inserts
|
||||
- Bulk updates and deletes by filter
|
||||
- Up to 10,000 rows per table, 100 tables per workspace
|
||||
|
||||
## Creating Tables
|
||||
|
||||
Tables are created from the **Tables** section in the sidebar. Each table requires:
|
||||
- **Name**: Alphanumeric with underscores (e.g., `customer_leads`)
|
||||
- **Description**: Optional description of the table's purpose
|
||||
- **Schema**: Define columns with name, type, and optional constraints
|
||||
|
||||
### Column Types
|
||||
|
||||
| Type | Description | Example Values |
|
||||
|------|-------------|----------------|
|
||||
| `string` | Text data | `"John Doe"`, `"active"` |
|
||||
| `number` | Numeric data | `42`, `99.99` |
|
||||
| `boolean` | True/false values | `true`, `false` |
|
||||
| `date` | Date/time values | `"2024-01-15T10:30:00Z"` |
|
||||
| `json` | Complex nested data | `{"address": {"city": "NYC"}}` |
|
||||
|
||||
### Column Constraints
|
||||
|
||||
- **Required**: Column must have a value (cannot be null)
|
||||
- **Unique**: Values must be unique across all rows (enables upsert matching)
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Create and manage custom data tables. Store, query, and manipulate structured data within workflows.
|
||||
|
||||
## Tools
|
||||
|
||||
### `table_query_rows`
|
||||
|
||||
Query rows from a table with filtering, sorting, and pagination
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `tableId` | string | Yes | Table ID |
|
||||
| `filter` | object | No | Filter conditions using MongoDB-style operators |
|
||||
| `sort` | object | No | Sort order as \{column: "asc"\|"desc"\} |
|
||||
| `limit` | number | No | Maximum rows to return \(default: 100, max: 1000\) |
|
||||
| `offset` | number | No | Number of rows to skip \(default: 0\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `success` | boolean | Whether query succeeded |
|
||||
| `rows` | array | Query result rows |
|
||||
| `rowCount` | number | Number of rows returned |
|
||||
| `totalCount` | number | Total rows matching filter |
|
||||
| `limit` | number | Limit used in query |
|
||||
| `offset` | number | Offset used in query |
|
||||
|
||||
### `table_insert_row`
|
||||
|
||||
Insert a new row into a table
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `tableId` | string | Yes | Table ID |
|
||||
| `data` | object | Yes | Row data as JSON object matching the table schema |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `success` | boolean | Whether row was inserted |
|
||||
| `row` | object | Inserted row data including generated ID |
|
||||
| `message` | string | Status message |
|
||||
|
||||
### `table_upsert_row`
|
||||
|
||||
Insert or update a row based on unique column constraints. If a row with matching unique field exists, update it; otherwise insert a new row.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `tableId` | string | Yes | Table ID |
|
||||
| `data` | object | Yes | Row data to insert or update |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `success` | boolean | Whether row was upserted |
|
||||
| `row` | object | Upserted row data |
|
||||
| `operation` | string | Operation performed: "insert" or "update" |
|
||||
| `message` | string | Status message |
|
||||
|
||||
### `table_batch_insert_rows`
|
||||
|
||||
Insert multiple rows at once (up to 1000 rows per batch)
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `tableId` | string | Yes | Table ID |
|
||||
| `rows` | array | Yes | Array of row data objects to insert |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `success` | boolean | Whether batch insert succeeded |
|
||||
| `rows` | array | Array of inserted rows with IDs |
|
||||
| `insertedCount` | number | Number of rows inserted |
|
||||
| `message` | string | Status message |
|
||||
|
||||
### `table_update_row`
|
||||
|
||||
Update a specific row by its ID
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `tableId` | string | Yes | Table ID |
|
||||
| `rowId` | string | Yes | Row ID to update |
|
||||
| `data` | object | Yes | Data to update \(partial update supported\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `success` | boolean | Whether row was updated |
|
||||
| `row` | object | Updated row data |
|
||||
| `message` | string | Status message |
|
||||
|
||||
### `table_update_rows_by_filter`
|
||||
|
||||
Update multiple rows matching a filter condition
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `tableId` | string | Yes | Table ID |
|
||||
| `filter` | object | Yes | Filter to match rows for update |
|
||||
| `data` | object | Yes | Data to apply to matching rows |
|
||||
| `limit` | number | No | Maximum rows to update \(default: 1000\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `success` | boolean | Whether update succeeded |
|
||||
| `updatedCount` | number | Number of rows updated |
|
||||
| `updatedRowIds` | array | IDs of updated rows |
|
||||
| `message` | string | Status message |
|
||||
|
||||
### `table_delete_row`
|
||||
|
||||
Delete a specific row by its ID
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `tableId` | string | Yes | Table ID |
|
||||
| `rowId` | string | Yes | Row ID to delete |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `success` | boolean | Whether row was deleted |
|
||||
| `deletedCount` | number | Number of rows deleted \(1 or 0\) |
|
||||
| `message` | string | Status message |
|
||||
|
||||
### `table_delete_rows_by_filter`
|
||||
|
||||
Delete multiple rows matching a filter condition
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `tableId` | string | Yes | Table ID |
|
||||
| `filter` | object | Yes | Filter to match rows for deletion |
|
||||
| `limit` | number | No | Maximum rows to delete \(default: 1000\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `success` | boolean | Whether delete succeeded |
|
||||
| `deletedCount` | number | Number of rows deleted |
|
||||
| `deletedRowIds` | array | IDs of deleted rows |
|
||||
| `message` | string | Status message |
|
||||
|
||||
### `table_get_row`
|
||||
|
||||
Get a single row by its ID
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `tableId` | string | Yes | Table ID |
|
||||
| `rowId` | string | Yes | Row ID to retrieve |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `success` | boolean | Whether row was found |
|
||||
| `row` | object | Row data |
|
||||
| `message` | string | Status message |
|
||||
|
||||
### `table_get_schema`
|
||||
|
||||
Get the schema definition for a table
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `tableId` | string | Yes | Table ID |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `success` | boolean | Whether schema was retrieved |
|
||||
| `name` | string | Table name |
|
||||
| `columns` | array | Array of column definitions |
|
||||
| `message` | string | Status message |
|
||||
|
||||
## Filter Operators
|
||||
|
||||
Filters use MongoDB-style operators for flexible querying:
|
||||
|
||||
| Operator | Description | Example |
|
||||
|----------|-------------|---------|
|
||||
| `$eq` | Equals | `{"status": {"$eq": "active"}}` or `{"status": "active"}` |
|
||||
| `$ne` | Not equals | `{"status": {"$ne": "deleted"}}` |
|
||||
| `$gt` | Greater than | `{"age": {"$gt": 18}}` |
|
||||
| `$gte` | Greater than or equal | `{"score": {"$gte": 80}}` |
|
||||
| `$lt` | Less than | `{"price": {"$lt": 100}}` |
|
||||
| `$lte` | Less than or equal | `{"quantity": {"$lte": 10}}` |
|
||||
| `$in` | In array | `{"status": {"$in": ["active", "pending"]}}` |
|
||||
| `$nin` | Not in array | `{"type": {"$nin": ["spam", "blocked"]}}` |
|
||||
| `$contains` | String contains | `{"email": {"$contains": "@gmail.com"}}` |
|
||||
|
||||
### Combining Filters
|
||||
|
||||
Multiple field conditions are combined with AND logic:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "active",
|
||||
"age": {"$gte": 18}
|
||||
}
|
||||
```
|
||||
|
||||
Use `$or` for OR logic:
|
||||
|
||||
```json
|
||||
{
|
||||
"$or": [
|
||||
{"status": "active"},
|
||||
{"status": "pending"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Sort Specification
|
||||
|
||||
Specify sort order with column names and direction:
|
||||
|
||||
```json
|
||||
{
|
||||
"createdAt": "desc"
|
||||
}
|
||||
```
|
||||
|
||||
Multi-column sorting:
|
||||
|
||||
```json
|
||||
{
|
||||
"priority": "desc",
|
||||
"name": "asc"
|
||||
}
|
||||
```
|
||||
|
||||
## Built-in Columns
|
||||
|
||||
Every row automatically includes:
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `id` | string | Unique row identifier |
|
||||
| `createdAt` | date | When the row was created |
|
||||
| `updatedAt` | date | When the row was last modified |
|
||||
|
||||
These can be used in filters and sorting.
|
||||
|
||||
## Limits
|
||||
|
||||
| Resource | Limit |
|
||||
|----------|-------|
|
||||
| Tables per workspace | 100 |
|
||||
| Rows per table | 10,000 |
|
||||
| Columns per table | 50 |
|
||||
| Max row size | 100KB |
|
||||
| String value length | 10,000 characters |
|
||||
| Query limit | 1,000 rows |
|
||||
| Batch insert size | 1,000 rows |
|
||||
| Bulk update/delete | 1,000 rows |
|
||||
|
||||
## Notes
|
||||
|
||||
- Category: `blocks`
|
||||
- Type: `table`
|
||||
- Tables are scoped to workspaces and accessible from any workflow within that workspace
|
||||
- Data persists across workflow executions
|
||||
- Use unique constraints to enable upsert functionality
|
||||
- The visual filter/sort builder provides an easy way to construct queries without writing JSON
|
||||
@@ -10,12 +10,20 @@ Despliega Sim en tu propia infraestructura con Docker o Kubernetes.
|
||||
|
||||
## Requisitos
|
||||
|
||||
| Recurso | Mínimo | Recomendado |
|
||||
|----------|---------|-------------|
|
||||
| CPU | 2 núcleos | 4+ núcleos |
|
||||
| RAM | 12 GB | 16+ GB |
|
||||
| Almacenamiento | 20 GB SSD | 50+ GB SSD |
|
||||
| Docker | 20.10+ | Última versión |
|
||||
| Recurso | Pequeño | Estándar | Producción |
|
||||
|----------|---------|----------|------------|
|
||||
| CPU | 2 núcleos | 4 núcleos | 8+ núcleos |
|
||||
| RAM | 12 GB | 16 GB | 32+ GB |
|
||||
| Almacenamiento | 20 GB SSD | 50 GB SSD | 100+ GB SSD |
|
||||
| Docker | 20.10+ | 20.10+ | Última versión |
|
||||
|
||||
**Pequeño**: Desarrollo, pruebas, usuario único (1-5 usuarios)
|
||||
**Estándar**: Equipos (5-50 usuarios), cargas de trabajo moderadas
|
||||
**Producción**: Equipos grandes (50+ usuarios), alta disponibilidad, ejecución intensiva de workflows
|
||||
|
||||
<Callout type="info">
|
||||
Los requisitos de recursos están determinados por la ejecución de workflows (sandboxing isolated-vm), procesamiento de archivos (análisis de documentos en memoria) y operaciones vectoriales (pgvector). La memoria suele ser el factor limitante, no la CPU. La telemetría de producción muestra que la aplicación principal usa 4-8 GB en promedio con picos de hasta 12 GB bajo carga pesada.
|
||||
</Callout>
|
||||
|
||||
## Inicio rápido
|
||||
|
||||
|
||||
@@ -10,12 +10,20 @@ Déployez Sim sur votre propre infrastructure avec Docker ou Kubernetes.
|
||||
|
||||
## Prérequis
|
||||
|
||||
| Ressource | Minimum | Recommandé |
|
||||
|----------|---------|-------------|
|
||||
| CPU | 2 cœurs | 4+ cœurs |
|
||||
| RAM | 12 Go | 16+ Go |
|
||||
| Stockage | 20 Go SSD | 50+ Go SSD |
|
||||
| Docker | 20.10+ | Dernière version |
|
||||
| Ressource | Petit | Standard | Production |
|
||||
|----------|-------|----------|------------|
|
||||
| CPU | 2 cœurs | 4 cœurs | 8+ cœurs |
|
||||
| RAM | 12 Go | 16 Go | 32+ Go |
|
||||
| Stockage | 20 Go SSD | 50 Go SSD | 100+ Go SSD |
|
||||
| Docker | 20.10+ | 20.10+ | Dernière version |
|
||||
|
||||
**Petit** : Développement, tests, utilisateur unique (1-5 utilisateurs)
|
||||
**Standard** : Équipes (5-50 utilisateurs), charges de travail modérées
|
||||
**Production** : Grandes équipes (50+ utilisateurs), haute disponibilité, exécution intensive de workflows
|
||||
|
||||
<Callout type="info">
|
||||
Les besoins en ressources sont déterminés par l'exécution des workflows (sandboxing isolated-vm), le traitement des fichiers (analyse de documents en mémoire) et les opérations vectorielles (pgvector). La mémoire est généralement le facteur limitant, pas le CPU. La télémétrie de production montre que l'application principale utilise 4-8 Go en moyenne avec des pics jusqu'à 12 Go sous forte charge.
|
||||
</Callout>
|
||||
|
||||
## Démarrage rapide
|
||||
|
||||
|
||||
@@ -10,12 +10,20 @@ DockerまたはKubernetesを使用して、自社のインフラストラクチ
|
||||
|
||||
## 要件
|
||||
|
||||
| リソース | 最小 | 推奨 |
|
||||
|----------|---------|-------------|
|
||||
| CPU | 2コア | 4+コア |
|
||||
| RAM | 12 GB | 16+ GB |
|
||||
| ストレージ | 20 GB SSD | 50+ GB SSD |
|
||||
| Docker | 20.10+ | 最新版 |
|
||||
| リソース | スモール | スタンダード | プロダクション |
|
||||
|----------|---------|-------------|----------------|
|
||||
| CPU | 2コア | 4コア | 8+コア |
|
||||
| RAM | 12 GB | 16 GB | 32+ GB |
|
||||
| ストレージ | 20 GB SSD | 50 GB SSD | 100+ GB SSD |
|
||||
| Docker | 20.10+ | 20.10+ | 最新版 |
|
||||
|
||||
**スモール**: 開発、テスト、シングルユーザー(1-5ユーザー)
|
||||
**スタンダード**: チーム(5-50ユーザー)、中程度のワークロード
|
||||
**プロダクション**: 大規模チーム(50+ユーザー)、高可用性、高負荷ワークフロー実行
|
||||
|
||||
<Callout type="info">
|
||||
リソース要件は、ワークフロー実行(isolated-vmサンドボックス)、ファイル処理(メモリ内ドキュメント解析)、ベクトル演算(pgvector)によって決まります。CPUよりもメモリが制約要因となることが多いです。本番環境のテレメトリによると、メインアプリは平均4-8 GB、高負荷時は最大12 GBを使用します。
|
||||
</Callout>
|
||||
|
||||
## クイックスタート
|
||||
|
||||
|
||||
@@ -10,12 +10,20 @@ import { Callout } from 'fumadocs-ui/components/callout'
|
||||
|
||||
## 要求
|
||||
|
||||
| 资源 | 最低要求 | 推荐配置 |
|
||||
|----------|---------|-------------|
|
||||
| CPU | 2 核 | 4 核及以上 |
|
||||
| 内存 | 12 GB | 16 GB 及以上 |
|
||||
| 存储 | 20 GB SSD | 50 GB 及以上 SSD |
|
||||
| Docker | 20.10+ | 最新版本 |
|
||||
| 资源 | 小型 | 标准 | 生产环境 |
|
||||
|----------|------|------|----------|
|
||||
| CPU | 2 核 | 4 核 | 8+ 核 |
|
||||
| 内存 | 12 GB | 16 GB | 32+ GB |
|
||||
| 存储 | 20 GB SSD | 50 GB SSD | 100+ GB SSD |
|
||||
| Docker | 20.10+ | 20.10+ | 最新版本 |
|
||||
|
||||
**小型**: 开发、测试、单用户(1-5 用户)
|
||||
**标准**: 团队(5-50 用户)、中等工作负载
|
||||
**生产环境**: 大型团队(50+ 用户)、高可用性、密集工作流执行
|
||||
|
||||
<Callout type="info">
|
||||
资源需求由工作流执行(isolated-vm 沙箱)、文件处理(内存中文档解析)和向量运算(pgvector)决定。内存通常是限制因素,而不是 CPU。生产遥测数据显示,主应用平均使用 4-8 GB,高负载时峰值可达 12 GB。
|
||||
</Callout>
|
||||
|
||||
## 快速开始
|
||||
|
||||
|
||||
BIN
apps/docs/public/static/quick-reference/add-env-variable.png
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
apps/docs/public/static/quick-reference/clear-chat.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
apps/docs/public/static/quick-reference/clear-terminal.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
apps/docs/public/static/quick-reference/copy-api.png
Normal file
|
After Width: | Height: | Size: 114 KiB |
BIN
apps/docs/public/static/quick-reference/copy-log.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
apps/docs/public/static/quick-reference/create-workflow.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
apps/docs/public/static/quick-reference/delete-block.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
apps/docs/public/static/quick-reference/deploy.png
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
BIN
apps/docs/public/static/quick-reference/disable-block.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 36 KiB |
BIN
apps/docs/public/static/quick-reference/filter-block.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
apps/docs/public/static/quick-reference/folder-context-menu.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
apps/docs/public/static/quick-reference/import-workflow.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
apps/docs/public/static/quick-reference/output-select.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
apps/docs/public/static/quick-reference/promote-deployment.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
apps/docs/public/static/quick-reference/rename-workflow.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
apps/docs/public/static/quick-reference/run-workflow.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
BIN
apps/docs/public/static/quick-reference/search-everything.png
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
apps/docs/public/static/quick-reference/stop-workflow.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
apps/docs/public/static/quick-reference/terminal-search.png
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
apps/docs/public/static/quick-reference/terminal.png
Normal file
|
After Width: | Height: | Size: 146 KiB |
BIN
apps/docs/public/static/quick-reference/test-chat.png
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
BIN
apps/docs/public/static/quick-reference/toggle-manual-mode.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
apps/docs/public/static/quick-reference/update-deployment.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
apps/docs/public/static/quick-reference/variable-reference.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
apps/docs/public/static/quick-reference/variables.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
apps/docs/public/static/quick-reference/view-deployment.png
Normal file
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 103 KiB |
@@ -2,10 +2,9 @@
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { ArrowRight, ChevronRight, Eye, EyeOff } from 'lucide-react'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -22,6 +21,7 @@ import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||
import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||
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 { SSOLoginButton } from '@/app/(auth)/components/sso-login-button'
|
||||
import { useBrandedButtonClass } from '@/hooks/use-branded-button-class'
|
||||
@@ -107,7 +107,6 @@ export default function LoginPage({
|
||||
const [passwordErrors, setPasswordErrors] = useState<string[]>([])
|
||||
const [showValidationError, setShowValidationError] = useState(false)
|
||||
const buttonClass = useBrandedButtonClass()
|
||||
const [isButtonHovered, setIsButtonHovered] = useState(false)
|
||||
|
||||
const [callbackUrl, setCallbackUrl] = useState('/workspace')
|
||||
const [isInviteFlow, setIsInviteFlow] = useState(false)
|
||||
@@ -115,7 +114,6 @@ export default function LoginPage({
|
||||
const [forgotPasswordOpen, setForgotPasswordOpen] = useState(false)
|
||||
const [forgotPasswordEmail, setForgotPasswordEmail] = useState('')
|
||||
const [isSubmittingReset, setIsSubmittingReset] = useState(false)
|
||||
const [isResetButtonHovered, setIsResetButtonHovered] = useState(false)
|
||||
const [resetStatus, setResetStatus] = useState<{
|
||||
type: 'success' | 'error' | null
|
||||
message: string
|
||||
@@ -184,6 +182,13 @@ export default function LoginPage({
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
|
||||
const redirectToVerify = (emailToVerify: string) => {
|
||||
if (typeof window !== 'undefined') {
|
||||
sessionStorage.setItem('verificationEmail', emailToVerify)
|
||||
}
|
||||
router.push('/verify')
|
||||
}
|
||||
|
||||
const formData = new FormData(e.currentTarget)
|
||||
const emailRaw = formData.get('email') as string
|
||||
const email = emailRaw.trim().toLowerCase()
|
||||
@@ -215,9 +220,9 @@ export default function LoginPage({
|
||||
onError: (ctx) => {
|
||||
logger.error('Login error:', ctx.error)
|
||||
|
||||
// EMAIL_NOT_VERIFIED is handled by the catch block which redirects to /verify
|
||||
if (ctx.error.code?.includes('EMAIL_NOT_VERIFIED')) {
|
||||
errorHandled = true
|
||||
redirectToVerify(email)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -285,10 +290,7 @@ export default function LoginPage({
|
||||
router.push(safeCallbackUrl)
|
||||
} catch (err: any) {
|
||||
if (err.message?.includes('not verified') || err.code?.includes('EMAIL_NOT_VERIFIED')) {
|
||||
if (typeof window !== 'undefined') {
|
||||
sessionStorage.setItem('verificationEmail', email)
|
||||
}
|
||||
router.push('/verify')
|
||||
redirectToVerify(email)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -491,24 +493,14 @@ export default function LoginPage({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
<BrandedButton
|
||||
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}
|
||||
loading={isLoading}
|
||||
loadingText='Signing in'
|
||||
>
|
||||
<span className='flex items-center gap-1'>
|
||||
{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>
|
||||
Sign in
|
||||
</BrandedButton>
|
||||
</form>
|
||||
)}
|
||||
|
||||
@@ -619,25 +611,15 @@ export default function LoginPage({
|
||||
<p>{resetStatus.message}</p>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
<BrandedButton
|
||||
type='button'
|
||||
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}
|
||||
loading={isSubmittingReset}
|
||||
loadingText='Sending'
|
||||
>
|
||||
<span className='flex items-center gap-1'>
|
||||
{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>
|
||||
Send Reset Link
|
||||
</BrandedButton>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { ArrowRight, ChevronRight, Eye, EyeOff } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||
import { useBrandedButtonClass } from '@/hooks/use-branded-button-class'
|
||||
import { BrandedButton } from '@/app/(auth)/components/branded-button'
|
||||
|
||||
interface RequestResetFormProps {
|
||||
email: string
|
||||
@@ -28,9 +27,6 @@ export function RequestResetForm({
|
||||
statusMessage,
|
||||
className,
|
||||
}: RequestResetFormProps) {
|
||||
const buttonClass = useBrandedButtonClass()
|
||||
const [isButtonHovered, setIsButtonHovered] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
onSubmit(email)
|
||||
@@ -68,24 +64,14 @@ export function RequestResetForm({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
<BrandedButton
|
||||
type='submit'
|
||||
disabled={isSubmitting}
|
||||
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'
|
||||
loading={isSubmitting}
|
||||
loadingText='Sending'
|
||||
>
|
||||
<span className='flex items-center gap-1'>
|
||||
{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>
|
||||
Send Reset Link
|
||||
</BrandedButton>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -112,8 +98,6 @@ export function SetNewPasswordForm({
|
||||
const [validationMessage, setValidationMessage] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
|
||||
const buttonClass = useBrandedButtonClass()
|
||||
const [isButtonHovered, setIsButtonHovered] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
@@ -243,24 +227,14 @@ export function SetNewPasswordForm({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
disabled={isSubmitting || !token}
|
||||
<BrandedButton
|
||||
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={isSubmitting || !token}
|
||||
loading={isSubmitting}
|
||||
loadingText='Resetting'
|
||||
>
|
||||
<span className='flex items-center gap-1'>
|
||||
{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>
|
||||
Reset Password
|
||||
</BrandedButton>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
|
||||
import { Suspense, useEffect, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { ArrowRight, ChevronRight, Eye, EyeOff } from 'lucide-react'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { client, useSession } from '@/lib/auth/auth-client'
|
||||
@@ -14,6 +13,7 @@ import { cn } from '@/lib/core/utils/cn'
|
||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||
import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||
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 { SSOLoginButton } from '@/app/(auth)/components/sso-login-button'
|
||||
import { useBrandedButtonClass } from '@/hooks/use-branded-button-class'
|
||||
@@ -97,7 +97,6 @@ function SignupFormContent({
|
||||
const [redirectUrl, setRedirectUrl] = useState('')
|
||||
const [isInviteFlow, setIsInviteFlow] = useState(false)
|
||||
const buttonClass = useBrandedButtonClass()
|
||||
const [isButtonHovered, setIsButtonHovered] = useState(false)
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [nameErrors, setNameErrors] = useState<string[]>([])
|
||||
@@ -476,24 +475,14 @@ function SignupFormContent({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
<BrandedButton
|
||||
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}
|
||||
loading={isLoading}
|
||||
loadingText='Creating account'
|
||||
>
|
||||
<span className='flex items-center gap-1'>
|
||||
{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>
|
||||
Create account
|
||||
</BrandedButton>
|
||||
</form>
|
||||
)}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { X } from 'lucide-react'
|
||||
import { Textarea } from '@/components/emcn'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
@@ -18,6 +17,7 @@ import { isHosted } from '@/lib/core/config/feature-flags'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||
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 Nav from '@/app/(landing)/components/nav/nav'
|
||||
|
||||
@@ -493,18 +493,17 @@ export default function CareersPage() {
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className='flex justify-end pt-2'>
|
||||
<Button
|
||||
<BrandedButton
|
||||
type='submit'
|
||||
disabled={isSubmitting || submitStatus === 'success'}
|
||||
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'
|
||||
size='lg'
|
||||
loading={isSubmitting}
|
||||
loadingText='Submitting'
|
||||
showArrow={false}
|
||||
fullWidth={false}
|
||||
className='min-w-[200px]'
|
||||
>
|
||||
{isSubmitting
|
||||
? 'Submitting...'
|
||||
: submitStatus === 'success'
|
||||
? 'Submitted'
|
||||
: 'Submit Application'}
|
||||
</Button>
|
||||
{submitStatus === 'success' ? 'Submitted' : 'Submit Application'}
|
||||
</BrandedButton>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -59,7 +59,7 @@ export default function StatusIndicator() {
|
||||
href={statusUrl}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className={`flex items-center gap-[6px] whitespace-nowrap text-[12px] transition-colors ${STATUS_COLORS[status]}`}
|
||||
className={`flex min-w-[165px] items-center gap-[6px] whitespace-nowrap text-[12px] transition-colors ${STATUS_COLORS[status]}`}
|
||||
aria-label={`System status: ${message}`}
|
||||
>
|
||||
<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 type { LoopBlockProps } from './landing-canvas/landing-block/loop-block'
|
||||
export { LoopBlock } from './landing-canvas/landing-block/loop-block'
|
||||
export type { TagProps } from './landing-canvas/landing-block/tag'
|
||||
export { Tag } from './landing-canvas/landing-block/tag'
|
||||
export type { SubBlockRowProps, TagProps } from './landing-canvas/landing-block/tag'
|
||||
export { SubBlockRow, Tag } from './landing-canvas/landing-block/tag'
|
||||
export type {
|
||||
LandingBlockNode,
|
||||
LandingCanvasProps,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import React from 'react'
|
||||
import { BookIcon } from 'lucide-react'
|
||||
import {
|
||||
Tag,
|
||||
type TagProps,
|
||||
SubBlockRow,
|
||||
type SubBlockRowProps,
|
||||
} from '@/app/(landing)/components/hero/components/landing-canvas/landing-block/tag'
|
||||
|
||||
/**
|
||||
* Data structure for a landing card component
|
||||
* Matches the workflow block structure from the application
|
||||
*/
|
||||
export interface LandingCardData {
|
||||
/** Icon element to display in the card header */
|
||||
@@ -15,8 +15,8 @@ export interface LandingCardData {
|
||||
color: string | '#f6f6f6'
|
||||
/** Name/title of the card */
|
||||
name: string
|
||||
/** Optional tags to display at the bottom of the card */
|
||||
tags?: TagProps[]
|
||||
/** Optional subblock rows to display below the header */
|
||||
tags?: SubBlockRowProps[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -28,7 +28,8 @@ export interface LandingBlockProps extends LandingCardData {
|
||||
}
|
||||
|
||||
/**
|
||||
* Landing block component that displays a card with icon, name, and optional tags
|
||||
* Landing block component that displays a card with icon, name, and optional subblock rows
|
||||
* Styled to match the application's workflow blocks
|
||||
* @param props - Component properties including icon, color, name, tags, and className
|
||||
* @returns A styled block card component
|
||||
*/
|
||||
@@ -39,33 +40,37 @@ export const LandingBlock = React.memo(function LandingBlock({
|
||||
tags,
|
||||
className,
|
||||
}: LandingBlockProps) {
|
||||
const hasContentBelowHeader = tags && tags.length > 0
|
||||
|
||||
return (
|
||||
<div
|
||||
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)',
|
||||
}}
|
||||
className={`z-10 flex w-[250px] flex-col rounded-[8px] border border-[#E5E5E5] bg-white ${className ?? ''}`}
|
||||
>
|
||||
<div className='flex w-full items-center justify-between'>
|
||||
<div className='flex items-center gap-2.5'>
|
||||
{/* Header - matches workflow-block.tsx header styling */}
|
||||
<div
|
||||
className={`flex items-center justify-between p-[8px] ${hasContentBelowHeader ? 'border-[#E5E5E5] border-b' : ''}`}
|
||||
>
|
||||
<div className='flex min-w-0 flex-1 items-center gap-[10px]'>
|
||||
<div
|
||||
className='flex h-6 w-6 items-center justify-center rounded-[8px] text-white'
|
||||
style={{ backgroundColor: color as string }}
|
||||
className='flex h-[24px] w-[24px] flex-shrink-0 items-center justify-center rounded-[6px]'
|
||||
style={{ background: color as string }}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
<p className='text-base text-card-foreground'>{name}</p>
|
||||
<span className='truncate font-medium text-[#171717] text-[16px]' title={name}>
|
||||
{name}
|
||||
</span>
|
||||
</div>
|
||||
<BookIcon className='h-4 w-4 text-muted-foreground' />
|
||||
</div>
|
||||
|
||||
{tags && tags.length > 0 ? (
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{/* Content - SubBlock Rows matching workflow-block.tsx */}
|
||||
{hasContentBelowHeader && (
|
||||
<div className='flex flex-col gap-[8px] p-[8px]'>
|
||||
{tags.map((tag) => (
|
||||
<Tag key={tag.label} icon={tag.icon} label={tag.label} />
|
||||
<SubBlockRow key={tag.label} icon={tag.icon} label={tag.label} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -7,9 +7,14 @@ import {
|
||||
type LandingCardData,
|
||||
} 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
|
||||
* Includes CSS animations and connection handles
|
||||
* Styled to match the application's workflow blocks
|
||||
* @param props - Component properties containing node data
|
||||
* @returns A React Flow compatible node component
|
||||
*/
|
||||
@@ -41,15 +46,15 @@ export const LandingNode = React.memo(function LandingNode({ data }: { data: Lan
|
||||
type='target'
|
||||
position={Position.Left}
|
||||
style={{
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
background: '#FEFEFE',
|
||||
border: '1px solid #E5E5E5',
|
||||
borderRadius: '50%',
|
||||
top: '50%',
|
||||
left: '-20px',
|
||||
width: '7px',
|
||||
height: '20px',
|
||||
background: '#D1D1D1',
|
||||
border: 'none',
|
||||
borderRadius: '2px 0 0 2px',
|
||||
top: `${HANDLE_Y_OFFSET}px`,
|
||||
left: '-7px',
|
||||
transform: 'translateY(-50%)',
|
||||
zIndex: 2,
|
||||
zIndex: 10,
|
||||
}}
|
||||
isConnectable={false}
|
||||
/>
|
||||
@@ -59,15 +64,15 @@ export const LandingNode = React.memo(function LandingNode({ data }: { data: Lan
|
||||
type='source'
|
||||
position={Position.Right}
|
||||
style={{
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
background: '#FEFEFE',
|
||||
border: '1px solid #E5E5E5',
|
||||
borderRadius: '50%',
|
||||
top: '50%',
|
||||
right: '-20px',
|
||||
width: '7px',
|
||||
height: '20px',
|
||||
background: '#D1D1D1',
|
||||
border: 'none',
|
||||
borderRadius: '0 2px 2px 0',
|
||||
top: `${HANDLE_Y_OFFSET}px`,
|
||||
right: '-7px',
|
||||
transform: 'translateY(-50%)',
|
||||
zIndex: 2,
|
||||
zIndex: 10,
|
||||
}}
|
||||
isConnectable={false}
|
||||
/>
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface LoopBlockProps {
|
||||
/**
|
||||
* Loop block container component that provides a styled container
|
||||
* for grouping related elements with a dashed border
|
||||
* Styled to match the application's subflow containers
|
||||
* @param props - Component properties including children and styling
|
||||
* @returns A styled loop container component
|
||||
*/
|
||||
@@ -29,33 +30,33 @@ export const LoopBlock = React.memo(function LoopBlock({
|
||||
style={{
|
||||
width: '1198px',
|
||||
height: '528px',
|
||||
borderRadius: '14px',
|
||||
background: 'rgba(59, 130, 246, 0.10)',
|
||||
borderRadius: '8px',
|
||||
background: 'rgba(59, 130, 246, 0.08)',
|
||||
position: 'relative',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{/* Custom dashed border with SVG */}
|
||||
{/* Custom dashed border with SVG - 8px border radius to match blocks */}
|
||||
<svg
|
||||
className='pointer-events-none absolute inset-0 h-full w-full'
|
||||
style={{ borderRadius: '14px' }}
|
||||
style={{ borderRadius: '8px' }}
|
||||
preserveAspectRatio='none'
|
||||
>
|
||||
<path
|
||||
className='landing-loop-animated-dash'
|
||||
d='M 1183.5 527.5
|
||||
L 14 527.5
|
||||
A 13.5 13.5 0 0 1 0.5 514
|
||||
L 0.5 14
|
||||
A 13.5 13.5 0 0 1 14 0.5
|
||||
L 1183.5 0.5
|
||||
A 13.5 13.5 0 0 1 1197 14
|
||||
L 1197 514
|
||||
A 13.5 13.5 0 0 1 1183.5 527.5 Z'
|
||||
d='M 1190 527.5
|
||||
L 8 527.5
|
||||
A 7.5 7.5 0 0 1 0.5 520
|
||||
L 0.5 8
|
||||
A 7.5 7.5 0 0 1 8 0.5
|
||||
L 1190 0.5
|
||||
A 7.5 7.5 0 0 1 1197.5 8
|
||||
L 1197.5 520
|
||||
A 7.5 7.5 0 0 1 1190 527.5 Z'
|
||||
fill='none'
|
||||
stroke='#3B82F6'
|
||||
strokeWidth='1'
|
||||
strokeDasharray='12 12'
|
||||
strokeDasharray='8 8'
|
||||
strokeLinecap='round'
|
||||
/>
|
||||
</svg>
|
||||
|
||||
@@ -1,25 +1,52 @@
|
||||
import React from 'react'
|
||||
|
||||
/**
|
||||
* Properties for a tag component
|
||||
* Properties for a subblock row component
|
||||
* Matches the SubBlockRow pattern from workflow-block.tsx
|
||||
*/
|
||||
export interface TagProps {
|
||||
/** Icon element to display in the tag */
|
||||
icon: React.ReactNode
|
||||
/** Text label for the tag */
|
||||
export interface SubBlockRowProps {
|
||||
/** Icon element to display (optional, for visual context) */
|
||||
icon?: React.ReactNode
|
||||
/** Text label for the row title */
|
||||
label: string
|
||||
/** Optional value to display on the right side */
|
||||
value?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Tag component for displaying labeled icons in a compact format
|
||||
* @param props - Tag properties including icon and label
|
||||
* @returns A styled tag component
|
||||
* Kept for backwards compatibility
|
||||
*/
|
||||
export const Tag = React.memo(function Tag({ icon, label }: TagProps) {
|
||||
export type TagProps = SubBlockRowProps
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<div className='flex w-fit items-center gap-1 rounded-[8px] border border-gray-300 bg-white px-2 py-0.5'>
|
||||
<div className='h-3 w-3 text-muted-foreground'>{icon}</div>
|
||||
<p className='text-muted-foreground text-xs leading-normal'>{label}</p>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<span className='min-w-0 truncate text-[#888888] text-[14px] capitalize' title={title}>
|
||||
{title}
|
||||
</span>
|
||||
{displayValue && (
|
||||
<span
|
||||
className='flex-1 truncate text-right text-[#171717] text-[14px]'
|
||||
title={displayValue}
|
||||
>
|
||||
{displayValue}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* Tag component - alias for SubBlockRow for backwards compatibility
|
||||
*/
|
||||
export const Tag = SubBlockRow
|
||||
|
||||
@@ -9,9 +9,10 @@ import { LandingFlow } from '@/app/(landing)/components/hero/components/landing-
|
||||
|
||||
/**
|
||||
* Visual constants for landing node dimensions
|
||||
* Matches BLOCK_DIMENSIONS from the application
|
||||
*/
|
||||
export const CARD_WIDTH = 256
|
||||
export const CARD_HEIGHT = 92
|
||||
export const CARD_WIDTH = 250
|
||||
export const CARD_HEIGHT = 100
|
||||
|
||||
/**
|
||||
* Landing block node with positioning information
|
||||
|
||||
@@ -4,33 +4,29 @@ import React from 'react'
|
||||
import { type EdgeProps, getSmoothStepPath, Position } from 'reactflow'
|
||||
|
||||
/**
|
||||
* Custom edge component with animated dotted line that floats between handles
|
||||
* Custom edge component with animated dashed line
|
||||
* Styled to match the application's workflow edges with rectangular handles
|
||||
* @param props - React Flow edge properties
|
||||
* @returns An animated dotted edge component
|
||||
* @returns An animated dashed edge component
|
||||
*/
|
||||
export const LandingEdge = React.memo(function LandingEdge(props: EdgeProps) {
|
||||
const { id, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, style, data } =
|
||||
props
|
||||
const { id, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, style } = props
|
||||
|
||||
// Adjust the connection points to create floating effect
|
||||
// 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
|
||||
// Adjust the connection points to connect flush with rectangular handles
|
||||
// Handle width is 7px, positioned at -7px from edge
|
||||
let adjustedSourceX = sourceX
|
||||
let adjustedTargetX = targetX
|
||||
|
||||
if (sourcePosition === Position.Right) {
|
||||
adjustedSourceX = sourceX + handleRadius + floatingGap
|
||||
adjustedSourceX = sourceX + 1
|
||||
} else if (sourcePosition === Position.Left) {
|
||||
adjustedSourceX = sourceX - handleRadius - floatingGap
|
||||
adjustedSourceX = sourceX - 1
|
||||
}
|
||||
|
||||
if (targetPosition === Position.Left) {
|
||||
adjustedTargetX = targetX - handleRadius - floatingGap
|
||||
adjustedTargetX = targetX - 1
|
||||
} else if (targetPosition === Position.Right) {
|
||||
adjustedTargetX = targetX + handleRadius + floatingGap
|
||||
adjustedTargetX = targetX + 1
|
||||
}
|
||||
|
||||
const [path] = getSmoothStepPath({
|
||||
@@ -40,8 +36,8 @@ export const LandingEdge = React.memo(function LandingEdge(props: EdgeProps) {
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
borderRadius: 20,
|
||||
offset: 10,
|
||||
borderRadius: 8,
|
||||
offset: 16,
|
||||
})
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,16 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import {
|
||||
ArrowUp,
|
||||
BinaryIcon,
|
||||
BookIcon,
|
||||
CalendarIcon,
|
||||
CodeIcon,
|
||||
Globe2Icon,
|
||||
MessageSquareIcon,
|
||||
VariableIcon,
|
||||
} from 'lucide-react'
|
||||
import { ArrowUp, CodeIcon } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { type Edge, type Node, Position } from 'reactflow'
|
||||
import {
|
||||
@@ -23,7 +14,6 @@ import {
|
||||
JiraIcon,
|
||||
LinearIcon,
|
||||
NotionIcon,
|
||||
OpenAIIcon,
|
||||
OutlookIcon,
|
||||
PackageSearchIcon,
|
||||
PineconeIcon,
|
||||
@@ -65,67 +55,56 @@ const SERVICE_TEMPLATES = {
|
||||
|
||||
/**
|
||||
* Landing blocks for the canvas preview
|
||||
* Styled to match the application's workflow blocks with subblock rows
|
||||
*/
|
||||
const LANDING_BLOCKS: LandingManualBlock[] = [
|
||||
{
|
||||
id: 'schedule',
|
||||
name: 'Schedule',
|
||||
color: '#7B68EE',
|
||||
icon: <ScheduleIcon className='h-4 w-4' />,
|
||||
icon: <ScheduleIcon className='h-[16px] w-[16px] text-white' />,
|
||||
positions: {
|
||||
mobile: { x: 8, y: 60 },
|
||||
tablet: { x: 40, y: 120 },
|
||||
desktop: { x: 60, y: 180 },
|
||||
},
|
||||
tags: [
|
||||
{ icon: <CalendarIcon className='h-3 w-3' />, label: '09:00AM Daily' },
|
||||
{ icon: <Globe2Icon className='h-3 w-3' />, label: 'PST' },
|
||||
],
|
||||
tags: [{ label: 'Time: 09:00AM Daily' }, { label: 'Timezone: PST' }],
|
||||
},
|
||||
{
|
||||
id: 'knowledge',
|
||||
name: 'Knowledge',
|
||||
color: '#00B0B0',
|
||||
icon: <PackageSearchIcon className='h-4 w-4' />,
|
||||
icon: <PackageSearchIcon className='h-[16px] w-[16px] text-white' />,
|
||||
positions: {
|
||||
mobile: { x: 120, y: 140 },
|
||||
tablet: { x: 220, y: 200 },
|
||||
desktop: { x: 420, y: 241 },
|
||||
},
|
||||
tags: [
|
||||
{ icon: <BookIcon className='h-3 w-3' />, label: 'Product Vector DB' },
|
||||
{ icon: <BinaryIcon className='h-3 w-3' />, label: 'Limit: 10' },
|
||||
],
|
||||
tags: [{ label: 'Source: Product Vector DB' }, { label: 'Limit: 10' }],
|
||||
},
|
||||
{
|
||||
id: 'agent',
|
||||
name: 'Agent',
|
||||
color: '#802FFF',
|
||||
icon: <AgentIcon className='h-4 w-4' />,
|
||||
icon: <AgentIcon className='h-[16px] w-[16px] text-white' />,
|
||||
positions: {
|
||||
mobile: { x: 340, y: 60 },
|
||||
tablet: { x: 540, y: 120 },
|
||||
desktop: { x: 880, y: 142 },
|
||||
},
|
||||
tags: [
|
||||
{ icon: <OpenAIIcon className='h-3 w-3' />, label: 'gpt-5' },
|
||||
{ icon: <MessageSquareIcon className='h-3 w-3' />, label: 'You are a support ag...' },
|
||||
],
|
||||
tags: [{ label: 'Model: gpt-5' }, { label: 'Prompt: You are a support ag...' }],
|
||||
},
|
||||
{
|
||||
id: 'function',
|
||||
name: 'Function',
|
||||
color: '#FF402F',
|
||||
icon: <CodeIcon className='h-4 w-4' />,
|
||||
icon: <CodeIcon className='h-[16px] w-[16px] text-white' />,
|
||||
positions: {
|
||||
mobile: { x: 480, y: 220 },
|
||||
tablet: { x: 740, y: 280 },
|
||||
desktop: { x: 880, y: 340 },
|
||||
},
|
||||
tags: [
|
||||
{ icon: <CodeIcon className='h-3 w-3' />, label: 'Python' },
|
||||
{ icon: <VariableIcon className='h-3 w-3' />, label: 'time = "2025-09-01...' },
|
||||
],
|
||||
tags: [{ label: 'Language: Python' }, { label: 'Code: time = "2025-09-01...' }],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -229,7 +229,7 @@ function PricingCard({
|
||||
*/
|
||||
export default function LandingPricing() {
|
||||
return (
|
||||
<section id='pricing' className='px-4 pt-[19px] sm:px-0 sm:pt-0' aria-label='Pricing plans'>
|
||||
<section id='pricing' className='px-4 pt-[23px] sm:px-0 sm:pt-[4px]' aria-label='Pricing plans'>
|
||||
<h2 className='sr-only'>Pricing Plans</h2>
|
||||
<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'>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useBrandConfig } from '@/lib/branding/branding'
|
||||
import { isHosted } from '@/lib/core/config/feature-flags'
|
||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||
import { getFormattedGitHubStars } from '@/app/(landing)/actions/github'
|
||||
import { useBrandedButtonClass } from '@/hooks/use-branded-button-class'
|
||||
|
||||
const logger = createLogger('nav')
|
||||
|
||||
@@ -20,11 +21,12 @@ interface NavProps {
|
||||
}
|
||||
|
||||
export default function Nav({ hideAuthButtons = false, variant = 'landing' }: NavProps = {}) {
|
||||
const [githubStars, setGithubStars] = useState('25.1k')
|
||||
const [githubStars, setGithubStars] = useState('26.1k')
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
const [isLoginHovered, setIsLoginHovered] = useState(false)
|
||||
const router = useRouter()
|
||||
const brand = useBrandConfig()
|
||||
const buttonClass = useBrandedButtonClass()
|
||||
|
||||
useEffect(() => {
|
||||
if (variant !== 'landing') return
|
||||
@@ -183,7 +185,7 @@ export default function Nav({ hideAuthButtons = false, variant = 'landing' }: Na
|
||||
href='/signup'
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
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]'
|
||||
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`}
|
||||
aria-label='Get started with Sim - Sign up for free'
|
||||
prefetch={true}
|
||||
>
|
||||
|
||||
27
apps/sim/app/(landing)/studio/[slug]/back-link.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
'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,7 +5,10 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/emcn'
|
||||
import { FAQ } from '@/lib/blog/faq'
|
||||
import { getAllPostMeta, getPostBySlug, getRelatedPosts } from '@/lib/blog/registry'
|
||||
import { buildArticleJsonLd, buildBreadcrumbJsonLd, buildPostMetadata } from '@/lib/blog/seo'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
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() {
|
||||
const posts = await getAllPostMeta()
|
||||
@@ -48,9 +51,7 @@ 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'>
|
||||
<div className='mb-6'>
|
||||
<Link href='/studio' className='text-gray-600 text-sm hover:text-gray-900'>
|
||||
← Back to Sim Studio
|
||||
</Link>
|
||||
<BackLink />
|
||||
</div>
|
||||
<div className='flex flex-col gap-8 md:flex-row md:gap-12'>
|
||||
<div className='w-full flex-shrink-0 md:w-[450px]'>
|
||||
@@ -75,28 +76,31 @@ export default async function Page({ params }: { params: Promise<{ slug: string
|
||||
>
|
||||
{post.title}
|
||||
</h1>
|
||||
<div className='mt-4 flex items-center gap-3'>
|
||||
{(post.authors || [post.author]).map((a, idx) => (
|
||||
<div key={idx} className='flex items-center gap-2'>
|
||||
{a?.avatarUrl ? (
|
||||
<Avatar className='size-6'>
|
||||
<AvatarImage src={a.avatarUrl} alt={a.name} />
|
||||
<AvatarFallback>{a.name.slice(0, 2)}</AvatarFallback>
|
||||
</Avatar>
|
||||
) : null}
|
||||
<Link
|
||||
href={a?.url || '#'}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer author'
|
||||
className='text-[14px] text-gray-600 leading-[1.5] hover:text-gray-900 sm:text-[16px]'
|
||||
itemProp='author'
|
||||
itemScope
|
||||
itemType='https://schema.org/Person'
|
||||
>
|
||||
<span itemProp='name'>{a?.name}</span>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
<div className='mt-4 flex items-center justify-between'>
|
||||
<div className='flex items-center gap-3'>
|
||||
{(post.authors || [post.author]).map((a, idx) => (
|
||||
<div key={idx} className='flex items-center gap-2'>
|
||||
{a?.avatarUrl ? (
|
||||
<Avatar className='size-6'>
|
||||
<AvatarImage src={a.avatarUrl} alt={a.name} />
|
||||
<AvatarFallback>{a.name.slice(0, 2)}</AvatarFallback>
|
||||
</Avatar>
|
||||
) : null}
|
||||
<Link
|
||||
href={a?.url || '#'}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer author'
|
||||
className='text-[14px] text-gray-600 leading-[1.5] hover:text-gray-900 sm:text-[16px]'
|
||||
itemProp='author'
|
||||
itemScope
|
||||
itemType='https://schema.org/Person'
|
||||
>
|
||||
<span itemProp='name'>{a?.name}</span>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<ShareButton url={`${getBaseUrl()}/studio/${slug}`} title={post.title} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
65
apps/sim/app/(landing)/studio/[slug]/share-button.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
'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) => {
|
||||
if (a.featured && !b.featured) return -1
|
||||
if (!a.featured && b.featured) return 1
|
||||
return 0
|
||||
return new Date(b.date).getTime() - new Date(a.date).getTime()
|
||||
})
|
||||
: filtered
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { AgentCapabilities, AgentSkill } from '@/lib/a2a/types'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { getRedisClient } from '@/lib/core/config/redis'
|
||||
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
|
||||
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('A2AAgentCardAPI')
|
||||
|
||||
@@ -95,6 +96,11 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<Ro
|
||||
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()
|
||||
|
||||
if (
|
||||
@@ -160,6 +166,11 @@ export async function DELETE(request: NextRequest, { params }: { params: Promise
|
||||
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))
|
||||
|
||||
logger.info(`Deleted A2A agent: ${agentId}`)
|
||||
@@ -194,6 +205,11 @@ export async function POST(request: NextRequest, { params }: { params: Promise<R
|
||||
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 action = body.action as 'publish' | 'unpublish' | 'refresh'
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { getBrandConfig } from '@/lib/branding/branding'
|
||||
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 { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { markExecutionCancelled } from '@/lib/execution/cancellation'
|
||||
@@ -1118,17 +1119,13 @@ async function handlePushNotificationSet(
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(params.pushNotificationConfig.url)
|
||||
if (url.protocol !== 'https:') {
|
||||
return NextResponse.json(
|
||||
createError(id, A2A_ERROR_CODES.INVALID_PARAMS, 'Push notification URL must use HTTPS'),
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
const urlValidation = validateExternalUrl(
|
||||
params.pushNotificationConfig.url,
|
||||
'Push notification URL'
|
||||
)
|
||||
if (!urlValidation.isValid) {
|
||||
return NextResponse.json(
|
||||
createError(id, A2A_ERROR_CODES.INVALID_PARAMS, 'Invalid push notification URL'),
|
||||
createError(id, A2A_ERROR_CODES.INVALID_PARAMS, urlValidation.error || 'Invalid URL'),
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,11 @@ import { createLogger } from '@sim/logger'
|
||||
import { and, desc, eq, inArray } from 'drizzle-orm'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { refreshOAuthToken } from '@/lib/oauth'
|
||||
import {
|
||||
getMicrosoftRefreshTokenExpiry,
|
||||
isMicrosoftProvider,
|
||||
PROACTIVE_REFRESH_THRESHOLD_DAYS,
|
||||
} from '@/lib/oauth/microsoft'
|
||||
|
||||
const logger = createLogger('OAuthUtilsAPI')
|
||||
|
||||
@@ -205,15 +210,32 @@ export async function refreshAccessTokenIfNeeded(
|
||||
}
|
||||
|
||||
// Decide if we should refresh: token missing OR expired
|
||||
const expiresAt = credential.accessTokenExpiresAt
|
||||
const accessTokenExpiresAt = credential.accessTokenExpiresAt
|
||||
const refreshTokenExpiresAt = credential.refreshTokenExpiresAt
|
||||
const now = new Date()
|
||||
const shouldRefresh =
|
||||
!!credential.refreshToken && (!credential.accessToken || (expiresAt && expiresAt <= now))
|
||||
|
||||
// Check if access token needs refresh (missing or expired)
|
||||
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
|
||||
|
||||
if (shouldRefresh) {
|
||||
logger.info(`[${requestId}] Token expired, attempting to refresh for credential`)
|
||||
logger.info(`[${requestId}] Refreshing token for credential`)
|
||||
try {
|
||||
const refreshedToken = await refreshOAuthToken(
|
||||
credential.providerId,
|
||||
@@ -227,11 +249,15 @@ export async function refreshAccessTokenIfNeeded(
|
||||
userId: credential.userId,
|
||||
hasRefreshToken: !!credential.refreshToken,
|
||||
})
|
||||
if (!accessTokenNeedsRefresh && accessToken) {
|
||||
logger.info(`[${requestId}] Proactive refresh failed but access token still valid`)
|
||||
return accessToken
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Prepare update data
|
||||
const updateData: any = {
|
||||
const updateData: Record<string, unknown> = {
|
||||
accessToken: refreshedToken.accessToken,
|
||||
accessTokenExpiresAt: new Date(Date.now() + refreshedToken.expiresIn * 1000),
|
||||
updatedAt: new Date(),
|
||||
@@ -243,6 +269,10 @@ export async function refreshAccessTokenIfNeeded(
|
||||
updateData.refreshToken = refreshedToken.refreshToken
|
||||
}
|
||||
|
||||
if (isMicrosoftProvider(credential.providerId)) {
|
||||
updateData.refreshTokenExpiresAt = getMicrosoftRefreshTokenExpiry()
|
||||
}
|
||||
|
||||
// Update the token in the database
|
||||
await db.update(account).set(updateData).where(eq(account.id, credentialId))
|
||||
|
||||
@@ -256,6 +286,10 @@ export async function refreshAccessTokenIfNeeded(
|
||||
credentialId,
|
||||
userId: credential.userId,
|
||||
})
|
||||
if (!accessTokenNeedsRefresh && accessToken) {
|
||||
logger.info(`[${requestId}] Proactive refresh failed but access token still valid`)
|
||||
return accessToken
|
||||
}
|
||||
return null
|
||||
}
|
||||
} else if (!accessToken) {
|
||||
@@ -277,10 +311,27 @@ export async function refreshTokenIfNeeded(
|
||||
credentialId: string
|
||||
): Promise<{ accessToken: string; refreshed: boolean }> {
|
||||
// Decide if we should refresh: token missing OR expired
|
||||
const expiresAt = credential.accessTokenExpiresAt
|
||||
const accessTokenExpiresAt = credential.accessTokenExpiresAt
|
||||
const refreshTokenExpiresAt = credential.refreshTokenExpiresAt
|
||||
const now = new Date()
|
||||
const shouldRefresh =
|
||||
!!credential.refreshToken && (!credential.accessToken || (expiresAt && expiresAt <= now))
|
||||
|
||||
// Check if access token needs refresh (missing or expired)
|
||||
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 (!shouldRefresh) {
|
||||
@@ -293,13 +344,17 @@ export async function refreshTokenIfNeeded(
|
||||
|
||||
if (!refreshResult) {
|
||||
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')
|
||||
}
|
||||
|
||||
const { accessToken: refreshedToken, expiresIn, refreshToken: newRefreshToken } = refreshResult
|
||||
|
||||
// Prepare update data
|
||||
const updateData: any = {
|
||||
const updateData: Record<string, unknown> = {
|
||||
accessToken: refreshedToken,
|
||||
accessTokenExpiresAt: new Date(Date.now() + expiresIn * 1000), // Use provider's expiry
|
||||
updatedAt: new Date(),
|
||||
@@ -311,6 +366,10 @@ export async function refreshTokenIfNeeded(
|
||||
updateData.refreshToken = newRefreshToken
|
||||
}
|
||||
|
||||
if (isMicrosoftProvider(credential.providerId)) {
|
||||
updateData.refreshTokenExpiresAt = getMicrosoftRefreshTokenExpiry()
|
||||
}
|
||||
|
||||
await db.update(account).set(updateData).where(eq(account.id, credentialId))
|
||||
|
||||
logger.info(`[${requestId}] Successfully refreshed access token`)
|
||||
@@ -331,6 +390,11 @@ 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)
|
||||
throw error
|
||||
}
|
||||
|
||||
@@ -104,17 +104,11 @@ export async function POST(req: NextRequest) {
|
||||
})
|
||||
|
||||
// Build execution params starting with LLM-provided arguments
|
||||
// Resolve all {{ENV_VAR}} references in the arguments
|
||||
// Resolve all {{ENV_VAR}} references in the arguments (deep for nested objects)
|
||||
const executionParams: Record<string, any> = resolveEnvVarReferences(
|
||||
toolArgs,
|
||||
decryptedEnvVars,
|
||||
{
|
||||
resolveExactMatch: true,
|
||||
allowEmbedded: true,
|
||||
trimKeys: true,
|
||||
onMissing: 'keep',
|
||||
deep: true,
|
||||
}
|
||||
{ deep: true }
|
||||
) as Record<string, any>
|
||||
|
||||
logger.info(`[${tracker.requestId}] Resolved env var references in arguments`, {
|
||||
|
||||
@@ -84,6 +84,14 @@ vi.mock('@/lib/execution/isolated-vm', () => ({
|
||||
|
||||
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', () => ({
|
||||
executeInE2B: vi.fn(),
|
||||
}))
|
||||
@@ -110,6 +118,24 @@ describe('Function Execute API Route', () => {
|
||||
})
|
||||
|
||||
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 () => {
|
||||
const req = createMockRequest('POST', {
|
||||
code: 'return "test"',
|
||||
@@ -313,7 +339,7 @@ describe('Function Execute API Route', () => {
|
||||
'block-2': 'world',
|
||||
},
|
||||
blockNameMapping: {
|
||||
validVar: 'block-1',
|
||||
validvar: 'block-1',
|
||||
another_valid: 'block-2',
|
||||
},
|
||||
})
|
||||
@@ -539,7 +565,7 @@ describe('Function Execute API Route', () => {
|
||||
'block-complex': complexData,
|
||||
},
|
||||
blockNameMapping: {
|
||||
complexData: 'block-complex',
|
||||
complexdata: 'block-complex',
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { isE2bEnabled } from '@/lib/core/config/feature-flags'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { executeInE2B } from '@/lib/execution/e2b'
|
||||
import { executeInIsolatedVM } from '@/lib/execution/isolated-vm'
|
||||
import { CodeLanguage, DEFAULT_CODE_LANGUAGE, isValidCodeLanguage } from '@/lib/execution/languages'
|
||||
import { escapeRegExp, normalizeName, REFERENCE } from '@/executor/constants'
|
||||
import { type OutputSchema, resolveBlockReference } from '@/executor/utils/block-reference'
|
||||
import { formatLiteralForCode } from '@/executor/utils/code-formatting'
|
||||
import {
|
||||
createEnvVarPattern,
|
||||
createWorkflowVariablePattern,
|
||||
} from '@/executor/utils/reference-validation'
|
||||
import { navigatePath } from '@/executor/variables/resolvers/reference'
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
@@ -386,7 +388,12 @@ function resolveWorkflowVariables(
|
||||
if (type === 'number') {
|
||||
variableValue = Number(variableValue)
|
||||
} else if (type === 'boolean') {
|
||||
variableValue = variableValue === 'true' || variableValue === true
|
||||
if (typeof variableValue === 'boolean') {
|
||||
// Already a boolean, keep as-is
|
||||
} else {
|
||||
const normalized = String(variableValue).toLowerCase().trim()
|
||||
variableValue = normalized === 'true'
|
||||
}
|
||||
} else if (type === 'json' && typeof variableValue === 'string') {
|
||||
try {
|
||||
variableValue = JSON.parse(variableValue)
|
||||
@@ -470,14 +477,17 @@ function resolveEnvironmentVariables(
|
||||
|
||||
function resolveTagVariables(
|
||||
code: string,
|
||||
blockData: Record<string, any>,
|
||||
blockData: Record<string, unknown>,
|
||||
blockNameMapping: Record<string, string>,
|
||||
contextVariables: Record<string, any>
|
||||
blockOutputSchemas: Record<string, OutputSchema>,
|
||||
contextVariables: Record<string, unknown>,
|
||||
language = 'javascript'
|
||||
): string {
|
||||
let resolvedCode = code
|
||||
const undefinedLiteral = language === 'python' ? 'None' : 'undefined'
|
||||
|
||||
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'
|
||||
)
|
||||
const tagMatches = resolvedCode.match(tagPattern) || []
|
||||
@@ -486,41 +496,37 @@ function resolveTagVariables(
|
||||
const tagName = match.slice(REFERENCE.START.length, -REFERENCE.END.length).trim()
|
||||
const pathParts = tagName.split(REFERENCE.PATH_DELIMITER)
|
||||
const blockName = pathParts[0]
|
||||
const fieldPath = pathParts.slice(1)
|
||||
|
||||
const blockId = blockNameMapping[blockName]
|
||||
if (!blockId) {
|
||||
const result = resolveBlockReference(blockName, fieldPath, {
|
||||
blockNameMapping,
|
||||
blockData,
|
||||
blockOutputSchemas,
|
||||
})
|
||||
|
||||
if (!result) {
|
||||
continue
|
||||
}
|
||||
|
||||
const blockOutput = blockData[blockId]
|
||||
if (blockOutput === undefined) {
|
||||
continue
|
||||
}
|
||||
|
||||
let tagValue: any
|
||||
if (pathParts.length === 1) {
|
||||
tagValue = blockOutput
|
||||
} else {
|
||||
tagValue = navigatePath(blockOutput, pathParts.slice(1))
|
||||
}
|
||||
let tagValue = result.value
|
||||
|
||||
if (tagValue === undefined) {
|
||||
resolvedCode = resolvedCode.replace(new RegExp(escapeRegExp(match), 'g'), undefinedLiteral)
|
||||
continue
|
||||
}
|
||||
|
||||
if (
|
||||
typeof tagValue === 'string' &&
|
||||
tagValue.length > 100 &&
|
||||
(tagValue.startsWith('{') || tagValue.startsWith('['))
|
||||
) {
|
||||
try {
|
||||
tagValue = JSON.parse(tagValue)
|
||||
} catch {
|
||||
// Keep as-is
|
||||
if (typeof tagValue === 'string') {
|
||||
const trimmed = tagValue.trimStart()
|
||||
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
||||
try {
|
||||
tagValue = JSON.parse(tagValue)
|
||||
} catch {
|
||||
// Keep as string if not valid JSON
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const safeVarName = `__tag_${tagName.replace(/[^a-zA-Z0-9_]/g, '_')}`
|
||||
const safeVarName = `__tag_${tagName.replace(/_/g, '_1').replace(/\./g, '_0')}`
|
||||
contextVariables[safeVarName] = tagValue
|
||||
resolvedCode = resolvedCode.replace(new RegExp(escapeRegExp(match), 'g'), safeVarName)
|
||||
}
|
||||
@@ -537,18 +543,27 @@ function resolveTagVariables(
|
||||
*/
|
||||
function resolveCodeVariables(
|
||||
code: string,
|
||||
params: Record<string, any>,
|
||||
params: Record<string, unknown>,
|
||||
envVars: Record<string, string> = {},
|
||||
blockData: Record<string, any> = {},
|
||||
blockData: Record<string, unknown> = {},
|
||||
blockNameMapping: Record<string, string> = {},
|
||||
workflowVariables: Record<string, any> = {}
|
||||
): { resolvedCode: string; contextVariables: Record<string, any> } {
|
||||
blockOutputSchemas: Record<string, OutputSchema> = {},
|
||||
workflowVariables: Record<string, unknown> = {},
|
||||
language = 'javascript'
|
||||
): { resolvedCode: string; contextVariables: Record<string, unknown> } {
|
||||
let resolvedCode = code
|
||||
const contextVariables: Record<string, any> = {}
|
||||
const contextVariables: Record<string, unknown> = {}
|
||||
|
||||
resolvedCode = resolveWorkflowVariables(resolvedCode, workflowVariables, contextVariables)
|
||||
resolvedCode = resolveEnvironmentVariables(resolvedCode, params, envVars, contextVariables)
|
||||
resolvedCode = resolveTagVariables(resolvedCode, blockData, blockNameMapping, contextVariables)
|
||||
resolvedCode = resolveTagVariables(
|
||||
resolvedCode,
|
||||
blockData,
|
||||
blockNameMapping,
|
||||
blockOutputSchemas,
|
||||
contextVariables,
|
||||
language
|
||||
)
|
||||
|
||||
return { resolvedCode, contextVariables }
|
||||
}
|
||||
@@ -573,6 +588,12 @@ export async function POST(req: NextRequest) {
|
||||
let resolvedCode = '' // Store resolved code for error reporting
|
||||
|
||||
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 { DEFAULT_EXECUTION_TIMEOUT_MS } = await import('@/lib/execution/constants')
|
||||
@@ -585,6 +606,7 @@ export async function POST(req: NextRequest) {
|
||||
envVars = {},
|
||||
blockData = {},
|
||||
blockNameMapping = {},
|
||||
blockOutputSchemas = {},
|
||||
workflowVariables = {},
|
||||
workflowId,
|
||||
isCustomTool = false,
|
||||
@@ -601,20 +623,21 @@ export async function POST(req: NextRequest) {
|
||||
isCustomTool,
|
||||
})
|
||||
|
||||
// Resolve variables in the code with workflow environment variables
|
||||
const lang = isValidCodeLanguage(language) ? language : DEFAULT_CODE_LANGUAGE
|
||||
|
||||
const codeResolution = resolveCodeVariables(
|
||||
code,
|
||||
executionParams,
|
||||
envVars,
|
||||
blockData,
|
||||
blockNameMapping,
|
||||
workflowVariables
|
||||
blockOutputSchemas,
|
||||
workflowVariables,
|
||||
lang
|
||||
)
|
||||
resolvedCode = codeResolution.resolvedCode
|
||||
const contextVariables = codeResolution.contextVariables
|
||||
|
||||
const lang = isValidCodeLanguage(language) ? language : DEFAULT_CODE_LANGUAGE
|
||||
|
||||
let jsImports = ''
|
||||
let jsRemainingCode = resolvedCode
|
||||
let hasImports = false
|
||||
@@ -670,7 +693,7 @@ export async function POST(req: NextRequest) {
|
||||
prologue += `const environmentVariables = JSON.parse(${JSON.stringify(JSON.stringify(envVars))});\n`
|
||||
prologueLineCount++
|
||||
for (const [k, v] of Object.entries(contextVariables)) {
|
||||
prologue += `const ${k} = JSON.parse(${JSON.stringify(JSON.stringify(v))});\n`
|
||||
prologue += `const ${k} = ${formatLiteralForCode(v, 'javascript')};\n`
|
||||
prologueLineCount++
|
||||
}
|
||||
|
||||
@@ -741,7 +764,7 @@ export async function POST(req: NextRequest) {
|
||||
prologue += `environmentVariables = json.loads(${JSON.stringify(JSON.stringify(envVars))})\n`
|
||||
prologueLineCount++
|
||||
for (const [k, v] of Object.entries(contextVariables)) {
|
||||
prologue += `${k} = json.loads(${JSON.stringify(JSON.stringify(v))})\n`
|
||||
prologue += `${k} = ${formatLiteralForCode(v, 'python')}\n`
|
||||
prologueLineCount++
|
||||
}
|
||||
const wrapped = [
|
||||
|
||||
@@ -157,7 +157,7 @@ describe('Knowledge Base Documents API Route', () => {
|
||||
expect(vi.mocked(getDocuments)).toHaveBeenCalledWith(
|
||||
'kb-123',
|
||||
{
|
||||
includeDisabled: false,
|
||||
enabledFilter: undefined,
|
||||
search: undefined,
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
@@ -166,7 +166,7 @@ describe('Knowledge Base Documents API Route', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should filter disabled documents by default', async () => {
|
||||
it('should return documents with default filter', async () => {
|
||||
const { checkKnowledgeBaseAccess } = await import('@/app/api/knowledge/utils')
|
||||
const { getDocuments } = await import('@/lib/knowledge/documents/service')
|
||||
|
||||
@@ -194,7 +194,7 @@ describe('Knowledge Base Documents API Route', () => {
|
||||
expect(vi.mocked(getDocuments)).toHaveBeenCalledWith(
|
||||
'kb-123',
|
||||
{
|
||||
includeDisabled: false,
|
||||
enabledFilter: undefined,
|
||||
search: undefined,
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
@@ -203,7 +203,7 @@ describe('Knowledge Base Documents API Route', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should include disabled documents when requested', async () => {
|
||||
it('should filter documents by enabled status when requested', async () => {
|
||||
const { checkKnowledgeBaseAccess } = await import('@/app/api/knowledge/utils')
|
||||
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?includeDisabled=true'
|
||||
const url = 'http://localhost:3000/api/knowledge/kb-123/documents?enabledFilter=disabled'
|
||||
const req = new Request(url, { method: 'GET' }) as any
|
||||
|
||||
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(
|
||||
'kb-123',
|
||||
{
|
||||
includeDisabled: true,
|
||||
enabledFilter: 'disabled',
|
||||
search: undefined,
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
@@ -361,8 +361,7 @@ describe('Knowledge Base Documents API Route', () => {
|
||||
expect(vi.mocked(createSingleDocument)).toHaveBeenCalledWith(
|
||||
validDocumentData,
|
||||
'kb-123',
|
||||
expect.any(String),
|
||||
'user-123'
|
||||
expect.any(String)
|
||||
)
|
||||
})
|
||||
|
||||
@@ -470,8 +469,7 @@ describe('Knowledge Base Documents API Route', () => {
|
||||
expect(vi.mocked(createDocumentRecords)).toHaveBeenCalledWith(
|
||||
validBulkData.documents,
|
||||
'kb-123',
|
||||
expect.any(String),
|
||||
'user-123'
|
||||
expect.any(String)
|
||||
)
|
||||
expect(vi.mocked(processDocumentsWithQueue)).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import {
|
||||
bulkDocumentOperation,
|
||||
bulkDocumentOperationByFilter,
|
||||
createDocumentRecords,
|
||||
createSingleDocument,
|
||||
getDocuments,
|
||||
@@ -57,13 +58,20 @@ const BulkCreateDocumentsSchema = z.object({
|
||||
bulk: z.literal(true),
|
||||
})
|
||||
|
||||
const BulkUpdateDocumentsSchema = z.object({
|
||||
operation: z.enum(['enable', 'disable', 'delete']),
|
||||
documentIds: z
|
||||
.array(z.string())
|
||||
.min(1, 'At least one document ID is required')
|
||||
.max(100, 'Cannot operate on more than 100 documents at once'),
|
||||
})
|
||||
const BulkUpdateDocumentsSchema = z
|
||||
.object({
|
||||
operation: z.enum(['enable', 'disable', 'delete']),
|
||||
documentIds: z
|
||||
.array(z.string())
|
||||
.min(1, 'At least one document ID is required')
|
||||
.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 }> }) {
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
@@ -90,14 +98,17 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
}
|
||||
|
||||
const url = new URL(req.url)
|
||||
const includeDisabled = url.searchParams.get('includeDisabled') === 'true'
|
||||
const enabledFilter = url.searchParams.get('enabledFilter') as
|
||||
| 'all'
|
||||
| 'enabled'
|
||||
| 'disabled'
|
||||
| null
|
||||
const search = url.searchParams.get('search') || undefined
|
||||
const limit = Number.parseInt(url.searchParams.get('limit') || '50')
|
||||
const offset = Number.parseInt(url.searchParams.get('offset') || '0')
|
||||
const sortByParam = url.searchParams.get('sortBy')
|
||||
const sortOrderParam = url.searchParams.get('sortOrder')
|
||||
|
||||
// Validate sort parameters
|
||||
const validSortFields: DocumentSortField[] = [
|
||||
'filename',
|
||||
'fileSize',
|
||||
@@ -105,6 +116,7 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
'chunkCount',
|
||||
'uploadedAt',
|
||||
'processingStatus',
|
||||
'enabled',
|
||||
]
|
||||
const validSortOrders: SortOrder[] = ['asc', 'desc']
|
||||
|
||||
@@ -120,7 +132,7 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
const result = await getDocuments(
|
||||
knowledgeBaseId,
|
||||
{
|
||||
includeDisabled,
|
||||
enabledFilter: enabledFilter || undefined,
|
||||
search,
|
||||
limit,
|
||||
offset,
|
||||
@@ -190,8 +202,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
const createdDocuments = await createDocumentRecords(
|
||||
validatedData.documents,
|
||||
knowledgeBaseId,
|
||||
requestId,
|
||||
userId
|
||||
requestId
|
||||
)
|
||||
|
||||
logger.info(
|
||||
@@ -250,16 +261,10 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
throw validationError
|
||||
}
|
||||
} else {
|
||||
// Handle single document creation
|
||||
try {
|
||||
const validatedData = CreateDocumentSchema.parse(body)
|
||||
|
||||
const newDocument = await createSingleDocument(
|
||||
validatedData,
|
||||
knowledgeBaseId,
|
||||
requestId,
|
||||
userId
|
||||
)
|
||||
const newDocument = await createSingleDocument(validatedData, knowledgeBaseId, requestId)
|
||||
|
||||
try {
|
||||
const { PlatformEvents } = await import('@/lib/core/telemetry')
|
||||
@@ -294,7 +299,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
} catch (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 isStorageLimitError =
|
||||
errorMessage.includes('Storage limit exceeded') || errorMessage.includes('storage limit')
|
||||
@@ -331,16 +335,22 @@ export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id
|
||||
|
||||
try {
|
||||
const validatedData = BulkUpdateDocumentsSchema.parse(body)
|
||||
const { operation, documentIds } = validatedData
|
||||
const { operation, documentIds, selectAll, enabledFilter } = validatedData
|
||||
|
||||
try {
|
||||
const result = await bulkDocumentOperation(
|
||||
knowledgeBaseId,
|
||||
operation,
|
||||
documentIds,
|
||||
requestId,
|
||||
session.user.id
|
||||
)
|
||||
let result
|
||||
if (selectAll) {
|
||||
result = await bulkDocumentOperationByFilter(
|
||||
knowledgeBaseId,
|
||||
operation,
|
||||
enabledFilter,
|
||||
requestId
|
||||
)
|
||||
} 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({
|
||||
success: true,
|
||||
|
||||
@@ -408,6 +408,7 @@ describe('Knowledge Search Utils', () => {
|
||||
input: ['test query'],
|
||||
model: 'text-embedding-3-small',
|
||||
encoding_format: 'float',
|
||||
dimensions: 1536,
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { getEffectiveDecryptedEnv } from '@/lib/environment/utils'
|
||||
import { McpClient } from '@/lib/mcp/client'
|
||||
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
|
||||
import type { McpServerConfig, McpTransport } from '@/lib/mcp/types'
|
||||
import { resolveMcpConfigEnvVars } from '@/lib/mcp/resolve-config'
|
||||
import type { McpTransport } from '@/lib/mcp/types'
|
||||
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
|
||||
import { resolveEnvVarReferences } from '@/executor/utils/reference-validation'
|
||||
|
||||
const logger = createLogger('McpServerTestAPI')
|
||||
|
||||
@@ -19,30 +18,6 @@ function isUrlBasedTransport(transport: McpTransport): boolean {
|
||||
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 {
|
||||
name: string
|
||||
transport: McpTransport
|
||||
@@ -96,39 +71,30 @@ export const POST = withMcpAuth('write')(
|
||||
)
|
||||
}
|
||||
|
||||
let resolvedUrl = body.url
|
||||
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 = {
|
||||
// Build initial config for resolution
|
||||
const initialConfig = {
|
||||
id: `test-${requestId}`,
|
||||
name: body.name,
|
||||
transport: body.transport,
|
||||
url: resolvedUrl,
|
||||
headers: resolvedHeaders,
|
||||
url: body.url,
|
||||
headers: body.headers || {},
|
||||
timeout: body.timeout || 10000,
|
||||
retries: 1, // Only one retry for tests
|
||||
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 = {
|
||||
requireConsent: false,
|
||||
auditLevel: 'none' as const,
|
||||
|
||||
@@ -1,204 +0,0 @@
|
||||
import { db } from '@sim/db'
|
||||
import { member, permissions, user, workspace } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, or } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
|
||||
const logger = createLogger('OrganizationWorkspacesAPI')
|
||||
|
||||
/**
|
||||
* GET /api/organizations/[id]/workspaces
|
||||
* Get workspaces related to the organization with optional filtering
|
||||
* Query parameters:
|
||||
* - ?available=true - Only workspaces where user can invite others (admin permissions)
|
||||
* - ?member=userId - Workspaces where specific member has access
|
||||
*/
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: organizationId } = await params
|
||||
const url = new URL(request.url)
|
||||
const availableOnly = url.searchParams.get('available') === 'true'
|
||||
const memberId = url.searchParams.get('member')
|
||||
|
||||
// Verify user is a member of this organization
|
||||
const memberEntry = await db
|
||||
.select()
|
||||
.from(member)
|
||||
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
|
||||
.limit(1)
|
||||
|
||||
if (memberEntry.length === 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Forbidden - Not a member of this organization',
|
||||
},
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const userRole = memberEntry[0].role
|
||||
const hasAdminAccess = ['owner', 'admin'].includes(userRole)
|
||||
|
||||
if (availableOnly) {
|
||||
// Get workspaces where user has admin permissions (can invite others)
|
||||
const availableWorkspaces = await db
|
||||
.select({
|
||||
id: workspace.id,
|
||||
name: workspace.name,
|
||||
ownerId: workspace.ownerId,
|
||||
createdAt: workspace.createdAt,
|
||||
isOwner: eq(workspace.ownerId, session.user.id),
|
||||
permissionType: permissions.permissionType,
|
||||
})
|
||||
.from(workspace)
|
||||
.leftJoin(
|
||||
permissions,
|
||||
and(
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.entityId, workspace.id),
|
||||
eq(permissions.userId, session.user.id)
|
||||
)
|
||||
)
|
||||
.where(
|
||||
or(
|
||||
// User owns the workspace
|
||||
eq(workspace.ownerId, session.user.id),
|
||||
// User has admin permission on the workspace
|
||||
and(
|
||||
eq(permissions.userId, session.user.id),
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.permissionType, 'admin')
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
// Filter and format the results
|
||||
const workspacesWithInvitePermission = availableWorkspaces
|
||||
.filter((workspace) => {
|
||||
// Include if user owns the workspace OR has admin permission
|
||||
return workspace.isOwner || workspace.permissionType === 'admin'
|
||||
})
|
||||
.map((workspace) => ({
|
||||
id: workspace.id,
|
||||
name: workspace.name,
|
||||
isOwner: workspace.isOwner,
|
||||
canInvite: true, // All returned workspaces have invite permission
|
||||
createdAt: workspace.createdAt,
|
||||
}))
|
||||
|
||||
logger.info('Retrieved available workspaces for organization member', {
|
||||
organizationId,
|
||||
userId: session.user.id,
|
||||
workspaceCount: workspacesWithInvitePermission.length,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
workspaces: workspacesWithInvitePermission,
|
||||
totalCount: workspacesWithInvitePermission.length,
|
||||
filter: 'available',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (memberId && hasAdminAccess) {
|
||||
// Get workspaces where specific member has access (admin only)
|
||||
const memberWorkspaces = await db
|
||||
.select({
|
||||
id: workspace.id,
|
||||
name: workspace.name,
|
||||
ownerId: workspace.ownerId,
|
||||
isOwner: eq(workspace.ownerId, memberId),
|
||||
permissionType: permissions.permissionType,
|
||||
createdAt: permissions.createdAt,
|
||||
})
|
||||
.from(workspace)
|
||||
.leftJoin(
|
||||
permissions,
|
||||
and(
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.entityId, workspace.id),
|
||||
eq(permissions.userId, memberId)
|
||||
)
|
||||
)
|
||||
.where(
|
||||
or(
|
||||
// Member owns the workspace
|
||||
eq(workspace.ownerId, memberId),
|
||||
// Member has permissions on the workspace
|
||||
and(eq(permissions.userId, memberId), eq(permissions.entityType, 'workspace'))
|
||||
)
|
||||
)
|
||||
|
||||
const formattedWorkspaces = memberWorkspaces.map((workspace) => ({
|
||||
id: workspace.id,
|
||||
name: workspace.name,
|
||||
isOwner: workspace.isOwner,
|
||||
permission: workspace.permissionType,
|
||||
joinedAt: workspace.createdAt,
|
||||
createdAt: workspace.createdAt,
|
||||
}))
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
workspaces: formattedWorkspaces,
|
||||
totalCount: formattedWorkspaces.length,
|
||||
filter: 'member',
|
||||
memberId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Default: Get all workspaces (basic info only for regular members)
|
||||
if (!hasAdminAccess) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
workspaces: [],
|
||||
totalCount: 0,
|
||||
message: 'Workspace access information is only available to organization admins',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// For admins: Get summary of all workspaces
|
||||
const allWorkspaces = await db
|
||||
.select({
|
||||
id: workspace.id,
|
||||
name: workspace.name,
|
||||
ownerId: workspace.ownerId,
|
||||
createdAt: workspace.createdAt,
|
||||
ownerName: user.name,
|
||||
})
|
||||
.from(workspace)
|
||||
.leftJoin(user, eq(workspace.ownerId, user.id))
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
workspaces: allWorkspaces,
|
||||
totalCount: allWorkspaces.length,
|
||||
filter: 'all',
|
||||
},
|
||||
userRole,
|
||||
hasAdminAccess,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to get organization workspaces', { error })
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Internal server error',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,9 @@ import { account } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
|
||||
import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import type { StreamingExecution } from '@/executor/types'
|
||||
import { executeProviderRequest } from '@/providers'
|
||||
@@ -20,6 +22,11 @@ export async function POST(request: NextRequest) {
|
||||
const startTime = Date.now()
|
||||
|
||||
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`, {
|
||||
timestamp: new Date().toISOString(),
|
||||
userAgent: request.headers.get('User-Agent'),
|
||||
@@ -85,6 +92,13 @@ export async function POST(request: NextRequest) {
|
||||
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
|
||||
try {
|
||||
if (provider === 'vertex' && vertexCredential) {
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { deleteTable, type TableSchema } from '@/lib/table'
|
||||
import { accessError, checkAccess, normalizeColumn, verifyTableWorkspace } from '../utils'
|
||||
|
||||
const logger = createLogger('TableDetailAPI')
|
||||
|
||||
const GetTableSchema = z.object({
|
||||
workspaceId: z.string().min(1, 'Workspace ID is required'),
|
||||
})
|
||||
|
||||
interface TableRouteParams {
|
||||
params: Promise<{ tableId: string }>
|
||||
}
|
||||
|
||||
/** GET /api/table/[tableId] - Retrieves a single table's details. */
|
||||
export async function GET(request: NextRequest, { params }: TableRouteParams) {
|
||||
const requestId = generateRequestId()
|
||||
const { tableId } = await params
|
||||
|
||||
try {
|
||||
const authResult = await checkHybridAuth(request)
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized table access attempt`)
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const validated = GetTableSchema.parse({
|
||||
workspaceId: searchParams.get('workspaceId'),
|
||||
})
|
||||
|
||||
const result = await checkAccess(tableId, authResult.userId, 'read')
|
||||
if (!result.ok) return accessError(result, requestId, tableId)
|
||||
|
||||
const { table } = result
|
||||
|
||||
const isValidWorkspace = await verifyTableWorkspace(tableId, validated.workspaceId)
|
||||
if (!isValidWorkspace) {
|
||||
logger.warn(
|
||||
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}`
|
||||
)
|
||||
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Retrieved table ${tableId} for user ${authResult.userId}`)
|
||||
|
||||
const schemaData = table.schema as TableSchema
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
table: {
|
||||
id: table.id,
|
||||
name: table.name,
|
||||
description: table.description,
|
||||
schema: {
|
||||
columns: schemaData.columns.map(normalizeColumn),
|
||||
},
|
||||
rowCount: table.rowCount,
|
||||
maxRows: table.maxRows,
|
||||
createdAt:
|
||||
table.createdAt instanceof Date
|
||||
? table.createdAt.toISOString()
|
||||
: String(table.createdAt),
|
||||
updatedAt:
|
||||
table.updatedAt instanceof Date
|
||||
? table.updatedAt.toISOString()
|
||||
: String(table.updatedAt),
|
||||
},
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation error', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error getting table:`, error)
|
||||
return NextResponse.json({ error: 'Failed to get table' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/** DELETE /api/table/[tableId] - Deletes a table and all its rows. */
|
||||
export async function DELETE(request: NextRequest, { params }: TableRouteParams) {
|
||||
const requestId = generateRequestId()
|
||||
const { tableId } = await params
|
||||
|
||||
try {
|
||||
const authResult = await checkHybridAuth(request)
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized table delete attempt`)
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const validated = GetTableSchema.parse({
|
||||
workspaceId: searchParams.get('workspaceId'),
|
||||
})
|
||||
|
||||
const result = await checkAccess(tableId, authResult.userId, 'write')
|
||||
if (!result.ok) return accessError(result, requestId, tableId)
|
||||
|
||||
const { table } = result
|
||||
|
||||
const isValidWorkspace = await verifyTableWorkspace(tableId, validated.workspaceId)
|
||||
if (!isValidWorkspace) {
|
||||
logger.warn(
|
||||
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}`
|
||||
)
|
||||
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
await deleteTable(tableId, requestId)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
message: 'Table deleted successfully',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation error', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error deleting table:`, error)
|
||||
return NextResponse.json({ error: 'Failed to delete table' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,276 +0,0 @@
|
||||
import { db } from '@sim/db'
|
||||
import { userTableRows } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import type { RowData, TableSchema } from '@/lib/table'
|
||||
import { validateRowData } from '@/lib/table'
|
||||
import { accessError, checkAccess, verifyTableWorkspace } from '../../../utils'
|
||||
|
||||
const logger = createLogger('TableRowAPI')
|
||||
|
||||
const GetRowSchema = z.object({
|
||||
workspaceId: z.string().min(1, 'Workspace ID is required'),
|
||||
})
|
||||
|
||||
const UpdateRowSchema = z.object({
|
||||
workspaceId: z.string().min(1, 'Workspace ID is required'),
|
||||
data: z.record(z.unknown(), { required_error: 'Row data is required' }),
|
||||
})
|
||||
|
||||
const DeleteRowSchema = z.object({
|
||||
workspaceId: z.string().min(1, 'Workspace ID is required'),
|
||||
})
|
||||
|
||||
interface RowRouteParams {
|
||||
params: Promise<{ tableId: string; rowId: string }>
|
||||
}
|
||||
|
||||
/** GET /api/table/[tableId]/rows/[rowId] - Retrieves a single row. */
|
||||
export async function GET(request: NextRequest, { params }: RowRouteParams) {
|
||||
const requestId = generateRequestId()
|
||||
const { tableId, rowId } = await params
|
||||
|
||||
try {
|
||||
const authResult = await checkHybridAuth(request)
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const validated = GetRowSchema.parse({
|
||||
workspaceId: searchParams.get('workspaceId'),
|
||||
})
|
||||
|
||||
const result = await checkAccess(tableId, authResult.userId, 'read')
|
||||
if (!result.ok) return accessError(result, requestId, tableId)
|
||||
|
||||
const { table } = result
|
||||
|
||||
const isValidWorkspace = await verifyTableWorkspace(tableId, validated.workspaceId)
|
||||
if (!isValidWorkspace) {
|
||||
logger.warn(
|
||||
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}`
|
||||
)
|
||||
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
const [row] = await db
|
||||
.select({
|
||||
id: userTableRows.id,
|
||||
data: userTableRows.data,
|
||||
createdAt: userTableRows.createdAt,
|
||||
updatedAt: userTableRows.updatedAt,
|
||||
})
|
||||
.from(userTableRows)
|
||||
.where(
|
||||
and(
|
||||
eq(userTableRows.id, rowId),
|
||||
eq(userTableRows.tableId, tableId),
|
||||
eq(userTableRows.workspaceId, validated.workspaceId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!row) {
|
||||
return NextResponse.json({ error: 'Row not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Retrieved row ${rowId} from table ${tableId}`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
row: {
|
||||
id: row.id,
|
||||
data: row.data,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
},
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation error', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error getting row:`, error)
|
||||
return NextResponse.json({ error: 'Failed to get row' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/** PATCH /api/table/[tableId]/rows/[rowId] - Updates a single row (supports partial updates). */
|
||||
export async function PATCH(request: NextRequest, { params }: RowRouteParams) {
|
||||
const requestId = generateRequestId()
|
||||
const { tableId, rowId } = await params
|
||||
|
||||
try {
|
||||
const authResult = await checkHybridAuth(request)
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body: unknown = await request.json()
|
||||
const validated = UpdateRowSchema.parse(body)
|
||||
|
||||
const result = await checkAccess(tableId, authResult.userId, 'write')
|
||||
if (!result.ok) return accessError(result, requestId, tableId)
|
||||
|
||||
const { table } = result
|
||||
|
||||
const isValidWorkspace = await verifyTableWorkspace(tableId, validated.workspaceId)
|
||||
if (!isValidWorkspace) {
|
||||
logger.warn(
|
||||
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}`
|
||||
)
|
||||
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Fetch existing row to support partial updates
|
||||
const [existingRow] = await db
|
||||
.select({ data: userTableRows.data })
|
||||
.from(userTableRows)
|
||||
.where(
|
||||
and(
|
||||
eq(userTableRows.id, rowId),
|
||||
eq(userTableRows.tableId, tableId),
|
||||
eq(userTableRows.workspaceId, validated.workspaceId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!existingRow) {
|
||||
return NextResponse.json({ error: 'Row not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Merge existing data with incoming partial data (incoming takes precedence)
|
||||
const mergedData = {
|
||||
...(existingRow.data as RowData),
|
||||
...(validated.data as RowData),
|
||||
}
|
||||
|
||||
const validation = await validateRowData({
|
||||
rowData: mergedData,
|
||||
schema: table.schema as TableSchema,
|
||||
tableId,
|
||||
excludeRowId: rowId,
|
||||
})
|
||||
if (!validation.valid) return validation.response
|
||||
|
||||
const now = new Date()
|
||||
|
||||
const [updatedRow] = await db
|
||||
.update(userTableRows)
|
||||
.set({
|
||||
data: mergedData,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(userTableRows.id, rowId),
|
||||
eq(userTableRows.tableId, tableId),
|
||||
eq(userTableRows.workspaceId, validated.workspaceId)
|
||||
)
|
||||
)
|
||||
.returning()
|
||||
|
||||
if (!updatedRow) {
|
||||
return NextResponse.json({ error: 'Row not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Updated row ${rowId} in table ${tableId}`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
row: {
|
||||
id: updatedRow.id,
|
||||
data: updatedRow.data,
|
||||
createdAt: updatedRow.createdAt.toISOString(),
|
||||
updatedAt: updatedRow.updatedAt.toISOString(),
|
||||
},
|
||||
message: 'Row updated successfully',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation error', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error updating row:`, error)
|
||||
return NextResponse.json({ error: 'Failed to update row' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/** DELETE /api/table/[tableId]/rows/[rowId] - Deletes a single row. */
|
||||
export async function DELETE(request: NextRequest, { params }: RowRouteParams) {
|
||||
const requestId = generateRequestId()
|
||||
const { tableId, rowId } = await params
|
||||
|
||||
try {
|
||||
const authResult = await checkHybridAuth(request)
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body: unknown = await request.json()
|
||||
const validated = DeleteRowSchema.parse(body)
|
||||
|
||||
const result = await checkAccess(tableId, authResult.userId, 'write')
|
||||
if (!result.ok) return accessError(result, requestId, tableId)
|
||||
|
||||
const { table } = result
|
||||
|
||||
const isValidWorkspace = await verifyTableWorkspace(tableId, validated.workspaceId)
|
||||
if (!isValidWorkspace) {
|
||||
logger.warn(
|
||||
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}`
|
||||
)
|
||||
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
const [deletedRow] = await db
|
||||
.delete(userTableRows)
|
||||
.where(
|
||||
and(
|
||||
eq(userTableRows.id, rowId),
|
||||
eq(userTableRows.tableId, tableId),
|
||||
eq(userTableRows.workspaceId, validated.workspaceId)
|
||||
)
|
||||
)
|
||||
.returning()
|
||||
|
||||
if (!deletedRow) {
|
||||
return NextResponse.json({ error: 'Row not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Deleted row ${rowId} from table ${tableId}`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
message: 'Row deleted successfully',
|
||||
deletedCount: 1,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation error', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error deleting row:`, error)
|
||||
return NextResponse.json({ error: 'Failed to delete row' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,681 +0,0 @@
|
||||
import { db } from '@sim/db'
|
||||
import { userTableRows } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import type { Filter, RowData, Sort, TableSchema } from '@/lib/table'
|
||||
import {
|
||||
checkUniqueConstraintsDb,
|
||||
getUniqueColumns,
|
||||
TABLE_LIMITS,
|
||||
USER_TABLE_ROWS_SQL_NAME,
|
||||
validateBatchRows,
|
||||
validateRowAgainstSchema,
|
||||
validateRowData,
|
||||
validateRowSize,
|
||||
} from '@/lib/table'
|
||||
import { buildFilterClause, buildSortClause } from '@/lib/table/sql'
|
||||
import { accessError, checkAccess } from '../../utils'
|
||||
|
||||
const logger = createLogger('TableRowsAPI')
|
||||
|
||||
const InsertRowSchema = z.object({
|
||||
workspaceId: z.string().min(1, 'Workspace ID is required'),
|
||||
data: z.record(z.unknown(), { required_error: 'Row data is required' }),
|
||||
})
|
||||
|
||||
const BatchInsertRowsSchema = z.object({
|
||||
workspaceId: z.string().min(1, 'Workspace ID is required'),
|
||||
rows: z
|
||||
.array(z.record(z.unknown()), { required_error: 'Rows array is required' })
|
||||
.min(1, 'At least one row is required')
|
||||
.max(1000, 'Cannot insert more than 1000 rows per batch'),
|
||||
})
|
||||
|
||||
const QueryRowsSchema = z.object({
|
||||
workspaceId: z.string().min(1, 'Workspace ID is required'),
|
||||
filter: z.record(z.unknown()).optional(),
|
||||
sort: z.record(z.enum(['asc', 'desc'])).optional(),
|
||||
limit: z.coerce
|
||||
.number({ required_error: 'Limit must be a number' })
|
||||
.int('Limit must be an integer')
|
||||
.min(1, 'Limit must be at least 1')
|
||||
.max(TABLE_LIMITS.MAX_QUERY_LIMIT, `Limit cannot exceed ${TABLE_LIMITS.MAX_QUERY_LIMIT}`)
|
||||
.optional()
|
||||
.default(100),
|
||||
offset: z.coerce
|
||||
.number({ required_error: 'Offset must be a number' })
|
||||
.int('Offset must be an integer')
|
||||
.min(0, 'Offset must be 0 or greater')
|
||||
.optional()
|
||||
.default(0),
|
||||
})
|
||||
|
||||
const UpdateRowsByFilterSchema = z.object({
|
||||
workspaceId: z.string().min(1, 'Workspace ID is required'),
|
||||
filter: z.record(z.unknown(), { required_error: 'Filter criteria is required' }),
|
||||
data: z.record(z.unknown(), { required_error: 'Update data is required' }),
|
||||
limit: z.coerce
|
||||
.number({ required_error: 'Limit must be a number' })
|
||||
.int('Limit must be an integer')
|
||||
.min(1, 'Limit must be at least 1')
|
||||
.max(1000, 'Cannot update more than 1000 rows per operation')
|
||||
.optional(),
|
||||
})
|
||||
|
||||
const DeleteRowsByFilterSchema = z.object({
|
||||
workspaceId: z.string().min(1, 'Workspace ID is required'),
|
||||
filter: z.record(z.unknown(), { required_error: 'Filter criteria is required' }),
|
||||
limit: z.coerce
|
||||
.number({ required_error: 'Limit must be a number' })
|
||||
.int('Limit must be an integer')
|
||||
.min(1, 'Limit must be at least 1')
|
||||
.max(1000, 'Cannot delete more than 1000 rows per operation')
|
||||
.optional(),
|
||||
})
|
||||
|
||||
interface TableRowsRouteParams {
|
||||
params: Promise<{ tableId: string }>
|
||||
}
|
||||
|
||||
async function handleBatchInsert(
|
||||
requestId: string,
|
||||
tableId: string,
|
||||
body: z.infer<typeof BatchInsertRowsSchema>,
|
||||
userId: string
|
||||
): Promise<NextResponse> {
|
||||
const validated = BatchInsertRowsSchema.parse(body)
|
||||
|
||||
const accessResult = await checkAccess(tableId, userId, 'write')
|
||||
if (!accessResult.ok) return accessError(accessResult, requestId, tableId)
|
||||
|
||||
const { table } = accessResult
|
||||
|
||||
if (validated.workspaceId !== table.workspaceId) {
|
||||
logger.warn(
|
||||
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}`
|
||||
)
|
||||
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
const workspaceId = validated.workspaceId
|
||||
|
||||
const remainingCapacity = table.maxRows - table.rowCount
|
||||
if (remainingCapacity < validated.rows.length) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Insufficient capacity. Can only insert ${remainingCapacity} more rows (table has ${table.rowCount}/${table.maxRows} rows)`,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const validation = await validateBatchRows({
|
||||
rows: validated.rows as RowData[],
|
||||
schema: table.schema as TableSchema,
|
||||
tableId,
|
||||
})
|
||||
if (!validation.valid) return validation.response
|
||||
|
||||
const now = new Date()
|
||||
const rowsToInsert = validated.rows.map((data) => ({
|
||||
id: `row_${crypto.randomUUID().replace(/-/g, '')}`,
|
||||
tableId,
|
||||
workspaceId,
|
||||
data,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdBy: userId,
|
||||
}))
|
||||
|
||||
const insertedRows = await db.insert(userTableRows).values(rowsToInsert).returning()
|
||||
|
||||
logger.info(`[${requestId}] Batch inserted ${insertedRows.length} rows into table ${tableId}`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
rows: insertedRows.map((r) => ({
|
||||
id: r.id,
|
||||
data: r.data,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
updatedAt: r.updatedAt.toISOString(),
|
||||
})),
|
||||
insertedCount: insertedRows.length,
|
||||
message: `Successfully inserted ${insertedRows.length} rows`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** POST /api/table/[tableId]/rows - Inserts row(s). Supports single or batch insert. */
|
||||
export async function POST(request: NextRequest, { params }: TableRowsRouteParams) {
|
||||
const requestId = generateRequestId()
|
||||
const { tableId } = await params
|
||||
|
||||
try {
|
||||
const authResult = await checkHybridAuth(request)
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body: unknown = await request.json()
|
||||
|
||||
if (
|
||||
typeof body === 'object' &&
|
||||
body !== null &&
|
||||
'rows' in body &&
|
||||
Array.isArray((body as Record<string, unknown>).rows)
|
||||
) {
|
||||
return handleBatchInsert(
|
||||
requestId,
|
||||
tableId,
|
||||
body as z.infer<typeof BatchInsertRowsSchema>,
|
||||
authResult.userId
|
||||
)
|
||||
}
|
||||
|
||||
const validated = InsertRowSchema.parse(body)
|
||||
|
||||
const accessResult = await checkAccess(tableId, authResult.userId, 'write')
|
||||
if (!accessResult.ok) return accessError(accessResult, requestId, tableId)
|
||||
|
||||
const { table } = accessResult
|
||||
|
||||
if (validated.workspaceId !== table.workspaceId) {
|
||||
logger.warn(
|
||||
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}`
|
||||
)
|
||||
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
const workspaceId = validated.workspaceId
|
||||
const rowData = validated.data as RowData
|
||||
|
||||
const validation = await validateRowData({
|
||||
rowData,
|
||||
schema: table.schema as TableSchema,
|
||||
tableId,
|
||||
})
|
||||
if (!validation.valid) return validation.response
|
||||
|
||||
if (table.rowCount >= table.maxRows) {
|
||||
return NextResponse.json(
|
||||
{ error: `Table row limit reached (${table.maxRows} rows max)` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const rowId = `row_${crypto.randomUUID().replace(/-/g, '')}`
|
||||
const now = new Date()
|
||||
|
||||
const [row] = await db
|
||||
.insert(userTableRows)
|
||||
.values({
|
||||
id: rowId,
|
||||
tableId,
|
||||
workspaceId,
|
||||
data: validated.data,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdBy: authResult.userId,
|
||||
})
|
||||
.returning()
|
||||
|
||||
logger.info(`[${requestId}] Inserted row ${rowId} into table ${tableId}`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
row: {
|
||||
id: row.id,
|
||||
data: row.data,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
},
|
||||
message: 'Row inserted successfully',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation error', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error inserting row:`, error)
|
||||
return NextResponse.json({ error: 'Failed to insert row' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/** GET /api/table/[tableId]/rows - Queries rows with filtering, sorting, and pagination. */
|
||||
export async function GET(request: NextRequest, { params }: TableRowsRouteParams) {
|
||||
const requestId = generateRequestId()
|
||||
const { tableId } = await params
|
||||
|
||||
try {
|
||||
const authResult = await checkHybridAuth(request)
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const workspaceId = searchParams.get('workspaceId')
|
||||
const filterParam = searchParams.get('filter')
|
||||
const sortParam = searchParams.get('sort')
|
||||
const limit = searchParams.get('limit')
|
||||
const offset = searchParams.get('offset')
|
||||
|
||||
let filter: Record<string, unknown> | undefined
|
||||
let sort: Sort | undefined
|
||||
|
||||
try {
|
||||
if (filterParam) {
|
||||
filter = JSON.parse(filterParam) as Record<string, unknown>
|
||||
}
|
||||
if (sortParam) {
|
||||
sort = JSON.parse(sortParam) as Sort
|
||||
}
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid filter or sort JSON' }, { status: 400 })
|
||||
}
|
||||
|
||||
const validated = QueryRowsSchema.parse({
|
||||
workspaceId,
|
||||
filter,
|
||||
sort,
|
||||
limit,
|
||||
offset,
|
||||
})
|
||||
|
||||
const accessResult = await checkAccess(tableId, authResult.userId, 'read')
|
||||
if (!accessResult.ok) return accessError(accessResult, requestId, tableId)
|
||||
|
||||
const { table } = accessResult
|
||||
|
||||
if (validated.workspaceId !== table.workspaceId) {
|
||||
logger.warn(
|
||||
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}`
|
||||
)
|
||||
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
const baseConditions = [
|
||||
eq(userTableRows.tableId, tableId),
|
||||
eq(userTableRows.workspaceId, validated.workspaceId),
|
||||
]
|
||||
|
||||
if (validated.filter) {
|
||||
const filterClause = buildFilterClause(validated.filter as Filter, USER_TABLE_ROWS_SQL_NAME)
|
||||
if (filterClause) {
|
||||
baseConditions.push(filterClause)
|
||||
}
|
||||
}
|
||||
|
||||
let query = db
|
||||
.select({
|
||||
id: userTableRows.id,
|
||||
data: userTableRows.data,
|
||||
createdAt: userTableRows.createdAt,
|
||||
updatedAt: userTableRows.updatedAt,
|
||||
})
|
||||
.from(userTableRows)
|
||||
.where(and(...baseConditions))
|
||||
|
||||
if (validated.sort) {
|
||||
const schema = table.schema as TableSchema
|
||||
const sortClause = buildSortClause(validated.sort, USER_TABLE_ROWS_SQL_NAME, schema.columns)
|
||||
if (sortClause) {
|
||||
query = query.orderBy(sortClause) as typeof query
|
||||
}
|
||||
} else {
|
||||
query = query.orderBy(userTableRows.createdAt) as typeof query
|
||||
}
|
||||
|
||||
const countQuery = db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(userTableRows)
|
||||
.where(and(...baseConditions))
|
||||
|
||||
const [{ count: totalCount }] = await countQuery
|
||||
|
||||
const rows = await query.limit(validated.limit).offset(validated.offset)
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Queried ${rows.length} rows from table ${tableId} (total: ${totalCount})`
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
rows: rows.map((r) => ({
|
||||
id: r.id,
|
||||
data: r.data,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
updatedAt: r.updatedAt.toISOString(),
|
||||
})),
|
||||
rowCount: rows.length,
|
||||
totalCount: Number(totalCount),
|
||||
limit: validated.limit,
|
||||
offset: validated.offset,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation error', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error querying rows:`, error)
|
||||
return NextResponse.json({ error: 'Failed to query rows' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/** PUT /api/table/[tableId]/rows - Updates rows matching filter criteria. */
|
||||
export async function PUT(request: NextRequest, { params }: TableRowsRouteParams) {
|
||||
const requestId = generateRequestId()
|
||||
const { tableId } = await params
|
||||
|
||||
try {
|
||||
const authResult = await checkHybridAuth(request)
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body: unknown = await request.json()
|
||||
const validated = UpdateRowsByFilterSchema.parse(body)
|
||||
|
||||
const accessResult = await checkAccess(tableId, authResult.userId, 'write')
|
||||
if (!accessResult.ok) return accessError(accessResult, requestId, tableId)
|
||||
|
||||
const { table } = accessResult
|
||||
|
||||
if (validated.workspaceId !== table.workspaceId) {
|
||||
logger.warn(
|
||||
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}`
|
||||
)
|
||||
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
const updateData = validated.data as RowData
|
||||
|
||||
const sizeValidation = validateRowSize(updateData)
|
||||
if (!sizeValidation.valid) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid row data', details: sizeValidation.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const baseConditions = [
|
||||
eq(userTableRows.tableId, tableId),
|
||||
eq(userTableRows.workspaceId, validated.workspaceId),
|
||||
]
|
||||
|
||||
const filterClause = buildFilterClause(validated.filter as Filter, USER_TABLE_ROWS_SQL_NAME)
|
||||
if (filterClause) {
|
||||
baseConditions.push(filterClause)
|
||||
}
|
||||
|
||||
let matchingRowsQuery = db
|
||||
.select({
|
||||
id: userTableRows.id,
|
||||
data: userTableRows.data,
|
||||
})
|
||||
.from(userTableRows)
|
||||
.where(and(...baseConditions))
|
||||
|
||||
if (validated.limit) {
|
||||
matchingRowsQuery = matchingRowsQuery.limit(validated.limit) as typeof matchingRowsQuery
|
||||
}
|
||||
|
||||
const matchingRows = await matchingRowsQuery
|
||||
|
||||
if (matchingRows.length === 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
data: {
|
||||
message: 'No rows matched the filter criteria',
|
||||
updatedCount: 0,
|
||||
},
|
||||
},
|
||||
{ status: 200 }
|
||||
)
|
||||
}
|
||||
|
||||
if (matchingRows.length > TABLE_LIMITS.MAX_BULK_OPERATION_SIZE) {
|
||||
logger.warn(`[${requestId}] Updating ${matchingRows.length} rows. This may take some time.`)
|
||||
}
|
||||
|
||||
for (const row of matchingRows) {
|
||||
const existingData = row.data as RowData
|
||||
const mergedData = { ...existingData, ...updateData }
|
||||
const rowValidation = validateRowAgainstSchema(mergedData, table.schema as TableSchema)
|
||||
if (!rowValidation.valid) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Updated data does not match schema',
|
||||
details: rowValidation.errors,
|
||||
affectedRowId: row.id,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const uniqueColumns = getUniqueColumns(table.schema as TableSchema)
|
||||
if (uniqueColumns.length > 0) {
|
||||
// If updating multiple rows, check that updateData doesn't set any unique column
|
||||
// (would cause all rows to have the same value, violating uniqueness)
|
||||
if (matchingRows.length > 1) {
|
||||
const uniqueColumnsInUpdate = uniqueColumns.filter((col) => col.name in updateData)
|
||||
if (uniqueColumnsInUpdate.length > 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Cannot set unique column values when updating multiple rows',
|
||||
details: [
|
||||
`Columns with unique constraint: ${uniqueColumnsInUpdate.map((c) => c.name).join(', ')}. ` +
|
||||
`Updating ${matchingRows.length} rows with the same value would violate uniqueness.`,
|
||||
],
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Check unique constraints against database for each row
|
||||
for (const row of matchingRows) {
|
||||
const existingData = row.data as RowData
|
||||
const mergedData = { ...existingData, ...updateData }
|
||||
const uniqueValidation = await checkUniqueConstraintsDb(
|
||||
tableId,
|
||||
mergedData,
|
||||
table.schema as TableSchema,
|
||||
row.id
|
||||
)
|
||||
|
||||
if (!uniqueValidation.valid) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Unique constraint violation',
|
||||
details: uniqueValidation.errors,
|
||||
affectedRowId: row.id,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
let totalUpdated = 0
|
||||
|
||||
for (let i = 0; i < matchingRows.length; i += TABLE_LIMITS.UPDATE_BATCH_SIZE) {
|
||||
const batch = matchingRows.slice(i, i + TABLE_LIMITS.UPDATE_BATCH_SIZE)
|
||||
const updatePromises = batch.map((row) => {
|
||||
const existingData = row.data as RowData
|
||||
return trx
|
||||
.update(userTableRows)
|
||||
.set({
|
||||
data: { ...existingData, ...updateData },
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(userTableRows.id, row.id))
|
||||
})
|
||||
await Promise.all(updatePromises)
|
||||
totalUpdated += batch.length
|
||||
logger.info(
|
||||
`[${requestId}] Updated batch ${Math.floor(i / TABLE_LIMITS.UPDATE_BATCH_SIZE) + 1} (${totalUpdated}/${matchingRows.length} rows)`
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
logger.info(`[${requestId}] Updated ${matchingRows.length} rows in table ${tableId}`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
message: 'Rows updated successfully',
|
||||
updatedCount: matchingRows.length,
|
||||
updatedRowIds: matchingRows.map((r) => r.id),
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation error', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error updating rows by filter:`, error)
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
const detailedError = `Failed to update rows: ${errorMessage}`
|
||||
|
||||
return NextResponse.json({ error: detailedError }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/** DELETE /api/table/[tableId]/rows - Deletes rows matching filter criteria. */
|
||||
export async function DELETE(request: NextRequest, { params }: TableRowsRouteParams) {
|
||||
const requestId = generateRequestId()
|
||||
const { tableId } = await params
|
||||
|
||||
try {
|
||||
const authResult = await checkHybridAuth(request)
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body: unknown = await request.json()
|
||||
const validated = DeleteRowsByFilterSchema.parse(body)
|
||||
|
||||
const accessResult = await checkAccess(tableId, authResult.userId, 'write')
|
||||
if (!accessResult.ok) return accessError(accessResult, requestId, tableId)
|
||||
|
||||
const { table } = accessResult
|
||||
|
||||
if (validated.workspaceId !== table.workspaceId) {
|
||||
logger.warn(
|
||||
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}`
|
||||
)
|
||||
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
const baseConditions = [
|
||||
eq(userTableRows.tableId, tableId),
|
||||
eq(userTableRows.workspaceId, validated.workspaceId),
|
||||
]
|
||||
|
||||
const filterClause = buildFilterClause(validated.filter as Filter, USER_TABLE_ROWS_SQL_NAME)
|
||||
if (filterClause) {
|
||||
baseConditions.push(filterClause)
|
||||
}
|
||||
|
||||
let matchingRowsQuery = db
|
||||
.select({ id: userTableRows.id })
|
||||
.from(userTableRows)
|
||||
.where(and(...baseConditions))
|
||||
|
||||
if (validated.limit) {
|
||||
matchingRowsQuery = matchingRowsQuery.limit(validated.limit) as typeof matchingRowsQuery
|
||||
}
|
||||
|
||||
const matchingRows = await matchingRowsQuery
|
||||
|
||||
if (matchingRows.length === 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
data: {
|
||||
message: 'No rows matched the filter criteria',
|
||||
deletedCount: 0,
|
||||
},
|
||||
},
|
||||
{ status: 200 }
|
||||
)
|
||||
}
|
||||
|
||||
if (matchingRows.length > TABLE_LIMITS.DELETE_BATCH_SIZE) {
|
||||
logger.warn(`[${requestId}] Deleting ${matchingRows.length} rows. This may take some time.`)
|
||||
}
|
||||
|
||||
const rowIds = matchingRows.map((r) => r.id)
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
let totalDeleted = 0
|
||||
|
||||
for (let i = 0; i < rowIds.length; i += TABLE_LIMITS.DELETE_BATCH_SIZE) {
|
||||
const batch = rowIds.slice(i, i + TABLE_LIMITS.DELETE_BATCH_SIZE)
|
||||
await trx.delete(userTableRows).where(
|
||||
and(
|
||||
eq(userTableRows.tableId, tableId),
|
||||
eq(userTableRows.workspaceId, validated.workspaceId),
|
||||
sql`${userTableRows.id} = ANY(ARRAY[${sql.join(
|
||||
batch.map((id) => sql`${id}`),
|
||||
sql`, `
|
||||
)}])`
|
||||
)
|
||||
)
|
||||
totalDeleted += batch.length
|
||||
logger.info(
|
||||
`[${requestId}] Deleted batch ${Math.floor(i / TABLE_LIMITS.DELETE_BATCH_SIZE) + 1} (${totalDeleted}/${rowIds.length} rows)`
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
logger.info(`[${requestId}] Deleted ${matchingRows.length} rows from table ${tableId}`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
message: 'Rows deleted successfully',
|
||||
deletedCount: matchingRows.length,
|
||||
deletedRowIds: rowIds,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation error', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error deleting rows by filter:`, error)
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
const detailedError = `Failed to delete rows: ${errorMessage}`
|
||||
|
||||
return NextResponse.json({ error: detailedError }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
import { db } from '@sim/db'
|
||||
import { userTableRows } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, or, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import type { RowData, TableSchema } from '@/lib/table'
|
||||
import { getUniqueColumns, validateRowData } from '@/lib/table'
|
||||
import { accessError, checkAccess, verifyTableWorkspace } from '../../../utils'
|
||||
|
||||
const logger = createLogger('TableUpsertAPI')
|
||||
|
||||
const UpsertRowSchema = z.object({
|
||||
workspaceId: z.string().min(1, 'Workspace ID is required'),
|
||||
data: z.record(z.unknown(), { required_error: 'Row data is required' }),
|
||||
})
|
||||
|
||||
interface UpsertRouteParams {
|
||||
params: Promise<{ tableId: string }>
|
||||
}
|
||||
|
||||
/** POST /api/table/[tableId]/rows/upsert - Inserts or updates based on unique columns. */
|
||||
export async function POST(request: NextRequest, { params }: UpsertRouteParams) {
|
||||
const requestId = generateRequestId()
|
||||
const { tableId } = await params
|
||||
|
||||
try {
|
||||
const authResult = await checkHybridAuth(request)
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body: unknown = await request.json()
|
||||
const validated = UpsertRowSchema.parse(body)
|
||||
|
||||
const result = await checkAccess(tableId, authResult.userId, 'write')
|
||||
if (!result.ok) return accessError(result, requestId, tableId)
|
||||
|
||||
const { table } = result
|
||||
|
||||
const isValidWorkspace = await verifyTableWorkspace(tableId, validated.workspaceId)
|
||||
if (!isValidWorkspace) {
|
||||
logger.warn(
|
||||
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}`
|
||||
)
|
||||
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
const schema = table.schema as TableSchema
|
||||
const rowData = validated.data as RowData
|
||||
|
||||
const validation = await validateRowData({
|
||||
rowData,
|
||||
schema,
|
||||
tableId,
|
||||
checkUnique: false,
|
||||
})
|
||||
if (!validation.valid) return validation.response
|
||||
|
||||
const uniqueColumns = getUniqueColumns(schema)
|
||||
|
||||
if (uniqueColumns.length === 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'Upsert requires at least one unique column in the schema. Please add a unique constraint to a column or use insert instead.',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const uniqueFilters = uniqueColumns.map((col) => {
|
||||
const value = rowData[col.name]
|
||||
if (value === undefined || value === null) {
|
||||
return null
|
||||
}
|
||||
return sql`${userTableRows.data}->>${col.name} = ${String(value)}`
|
||||
})
|
||||
|
||||
const validUniqueFilters = uniqueFilters.filter((f): f is Exclude<typeof f, null> => f !== null)
|
||||
|
||||
if (validUniqueFilters.length === 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Upsert requires values for at least one unique field: ${uniqueColumns.map((c) => c.name).join(', ')}`,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const [existingRow] = await db
|
||||
.select()
|
||||
.from(userTableRows)
|
||||
.where(
|
||||
and(
|
||||
eq(userTableRows.tableId, tableId),
|
||||
eq(userTableRows.workspaceId, validated.workspaceId),
|
||||
or(...validUniqueFilters)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
const now = new Date()
|
||||
|
||||
if (!existingRow && table.rowCount >= table.maxRows) {
|
||||
return NextResponse.json(
|
||||
{ error: `Table row limit reached (${table.maxRows} rows max)` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const upsertResult = await db.transaction(async (trx) => {
|
||||
if (existingRow) {
|
||||
const [updatedRow] = await trx
|
||||
.update(userTableRows)
|
||||
.set({
|
||||
data: validated.data,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(userTableRows.id, existingRow.id))
|
||||
.returning()
|
||||
|
||||
return {
|
||||
row: updatedRow,
|
||||
operation: 'update' as const,
|
||||
}
|
||||
}
|
||||
|
||||
const [insertedRow] = await trx
|
||||
.insert(userTableRows)
|
||||
.values({
|
||||
id: `row_${crypto.randomUUID().replace(/-/g, '')}`,
|
||||
tableId,
|
||||
workspaceId: validated.workspaceId,
|
||||
data: validated.data,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdBy: authResult.userId,
|
||||
})
|
||||
.returning()
|
||||
|
||||
return {
|
||||
row: insertedRow,
|
||||
operation: 'insert' as const,
|
||||
}
|
||||
})
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Upserted (${upsertResult.operation}) row ${upsertResult.row.id} in table ${tableId}`
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
row: {
|
||||
id: upsertResult.row.id,
|
||||
data: upsertResult.row.data,
|
||||
createdAt: upsertResult.row.createdAt.toISOString(),
|
||||
updatedAt: upsertResult.row.updatedAt.toISOString(),
|
||||
},
|
||||
operation: upsertResult.operation,
|
||||
message: `Row ${upsertResult.operation === 'update' ? 'updated' : 'inserted'} successfully`,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation error', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error upserting row:`, error)
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
const detailedError = `Failed to upsert row: ${errorMessage}`
|
||||
|
||||
return NextResponse.json({ error: detailedError }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,293 +0,0 @@
|
||||
import { db } from '@sim/db'
|
||||
import { permissions, workspace } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import {
|
||||
canCreateTable,
|
||||
createTable,
|
||||
getWorkspaceTableLimits,
|
||||
listTables,
|
||||
TABLE_LIMITS,
|
||||
type TableSchema,
|
||||
} from '@/lib/table'
|
||||
import { normalizeColumn } from './utils'
|
||||
|
||||
const logger = createLogger('TableAPI')
|
||||
|
||||
const ColumnSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1, 'Column name is required')
|
||||
.max(
|
||||
TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH,
|
||||
`Column name must be ${TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH} characters or less`
|
||||
)
|
||||
.regex(
|
||||
/^[a-z_][a-z0-9_]*$/i,
|
||||
'Column name must start with a letter or underscore and contain only alphanumeric characters and underscores'
|
||||
),
|
||||
type: z.enum(['string', 'number', 'boolean', 'date', 'json'], {
|
||||
errorMap: () => ({
|
||||
message: 'Column type must be one of: string, number, boolean, date, json',
|
||||
}),
|
||||
}),
|
||||
required: z.boolean().optional().default(false),
|
||||
unique: z.boolean().optional().default(false),
|
||||
})
|
||||
|
||||
const CreateTableSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1, 'Table name is required')
|
||||
.max(
|
||||
TABLE_LIMITS.MAX_TABLE_NAME_LENGTH,
|
||||
`Table name must be ${TABLE_LIMITS.MAX_TABLE_NAME_LENGTH} characters or less`
|
||||
)
|
||||
.regex(
|
||||
/^[a-z_][a-z0-9_]*$/i,
|
||||
'Table name must start with a letter or underscore and contain only alphanumeric characters and underscores'
|
||||
),
|
||||
description: z
|
||||
.string()
|
||||
.max(
|
||||
TABLE_LIMITS.MAX_DESCRIPTION_LENGTH,
|
||||
`Description must be ${TABLE_LIMITS.MAX_DESCRIPTION_LENGTH} characters or less`
|
||||
)
|
||||
.optional(),
|
||||
schema: z.object({
|
||||
columns: z
|
||||
.array(ColumnSchema)
|
||||
.min(1, 'Table must have at least one column')
|
||||
.max(
|
||||
TABLE_LIMITS.MAX_COLUMNS_PER_TABLE,
|
||||
`Table cannot have more than ${TABLE_LIMITS.MAX_COLUMNS_PER_TABLE} columns`
|
||||
),
|
||||
}),
|
||||
workspaceId: z.string().min(1, 'Workspace ID is required'),
|
||||
})
|
||||
|
||||
const ListTablesSchema = z.object({
|
||||
workspaceId: z.string().min(1, 'Workspace ID is required'),
|
||||
})
|
||||
|
||||
interface WorkspaceAccessResult {
|
||||
hasAccess: boolean
|
||||
canWrite: boolean
|
||||
}
|
||||
|
||||
async function checkWorkspaceAccess(
|
||||
workspaceId: string,
|
||||
userId: string
|
||||
): Promise<WorkspaceAccessResult> {
|
||||
const [workspaceData] = await db
|
||||
.select({
|
||||
id: workspace.id,
|
||||
ownerId: workspace.ownerId,
|
||||
})
|
||||
.from(workspace)
|
||||
.where(eq(workspace.id, workspaceId))
|
||||
.limit(1)
|
||||
|
||||
if (!workspaceData) {
|
||||
return { hasAccess: false, canWrite: false }
|
||||
}
|
||||
|
||||
if (workspaceData.ownerId === userId) {
|
||||
return { hasAccess: true, canWrite: true }
|
||||
}
|
||||
|
||||
const [permission] = await db
|
||||
.select({
|
||||
permissionType: permissions.permissionType,
|
||||
})
|
||||
.from(permissions)
|
||||
.where(
|
||||
and(
|
||||
eq(permissions.userId, userId),
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.entityId, workspaceId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!permission) {
|
||||
return { hasAccess: false, canWrite: false }
|
||||
}
|
||||
|
||||
const canWrite = permission.permissionType === 'admin' || permission.permissionType === 'write'
|
||||
|
||||
return {
|
||||
hasAccess: true,
|
||||
canWrite,
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /api/table - Creates a new user-defined table. */
|
||||
export async function POST(request: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkHybridAuth(request)
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body: unknown = await request.json()
|
||||
const params = CreateTableSchema.parse(body)
|
||||
|
||||
const { hasAccess, canWrite } = await checkWorkspaceAccess(
|
||||
params.workspaceId,
|
||||
authResult.userId
|
||||
)
|
||||
|
||||
if (!hasAccess || !canWrite) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Check billing plan limits
|
||||
const existingTables = await listTables(params.workspaceId)
|
||||
const { canCreate, maxTables } = await canCreateTable(params.workspaceId, existingTables.length)
|
||||
|
||||
if (!canCreate) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Workspace has reached the maximum table limit (${maxTables}) for your plan. Please upgrade to create more tables.`,
|
||||
},
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get plan-based row limits
|
||||
const planLimits = await getWorkspaceTableLimits(params.workspaceId)
|
||||
const maxRowsPerTable = planLimits.maxRowsPerTable
|
||||
|
||||
const normalizedSchema: TableSchema = {
|
||||
columns: params.schema.columns.map(normalizeColumn),
|
||||
}
|
||||
|
||||
const table = await createTable(
|
||||
{
|
||||
name: params.name,
|
||||
description: params.description,
|
||||
schema: normalizedSchema,
|
||||
workspaceId: params.workspaceId,
|
||||
userId: authResult.userId,
|
||||
maxRows: maxRowsPerTable,
|
||||
},
|
||||
requestId
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
table: {
|
||||
id: table.id,
|
||||
name: table.name,
|
||||
description: table.description,
|
||||
schema: table.schema,
|
||||
rowCount: table.rowCount,
|
||||
maxRows: table.maxRows,
|
||||
createdAt:
|
||||
table.createdAt instanceof Date
|
||||
? table.createdAt.toISOString()
|
||||
: String(table.createdAt),
|
||||
updatedAt:
|
||||
table.updatedAt instanceof Date
|
||||
? table.updatedAt.toISOString()
|
||||
: String(table.updatedAt),
|
||||
},
|
||||
message: 'Table created successfully',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation error', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
if (
|
||||
error.message.includes('Invalid table name') ||
|
||||
error.message.includes('Invalid schema') ||
|
||||
error.message.includes('already exists') ||
|
||||
error.message.includes('maximum table limit')
|
||||
) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 })
|
||||
}
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error creating table:`, error)
|
||||
return NextResponse.json({ error: 'Failed to create table' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/** GET /api/table - Lists all tables in a workspace. */
|
||||
export async function GET(request: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkHybridAuth(request)
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const workspaceId = searchParams.get('workspaceId')
|
||||
|
||||
const validation = ListTablesSchema.safeParse({ workspaceId })
|
||||
if (!validation.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation error', details: validation.error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const params = validation.data
|
||||
|
||||
const { hasAccess } = await checkWorkspaceAccess(params.workspaceId, authResult.userId)
|
||||
|
||||
if (!hasAccess) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
const tables = await listTables(params.workspaceId)
|
||||
|
||||
logger.info(`[${requestId}] Listed ${tables.length} tables in workspace ${params.workspaceId}`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
tables: tables.map((t) => {
|
||||
const schemaData = t.schema as TableSchema
|
||||
return {
|
||||
...t,
|
||||
schema: {
|
||||
columns: schemaData.columns.map(normalizeColumn),
|
||||
},
|
||||
createdAt:
|
||||
t.createdAt instanceof Date ? t.createdAt.toISOString() : String(t.createdAt),
|
||||
updatedAt:
|
||||
t.updatedAt instanceof Date ? t.updatedAt.toISOString() : String(t.updatedAt),
|
||||
}
|
||||
}),
|
||||
totalCount: tables.length,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation error', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error listing tables:`, error)
|
||||
return NextResponse.json({ error: 'Failed to list tables' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { ColumnDefinition, TableDefinition } from '@/lib/table'
|
||||
import { getTableById } from '@/lib/table'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('TableUtils')
|
||||
|
||||
export interface TableAccessResult {
|
||||
hasAccess: true
|
||||
table: TableDefinition
|
||||
}
|
||||
|
||||
export interface TableAccessDenied {
|
||||
hasAccess: false
|
||||
notFound?: boolean
|
||||
reason?: string
|
||||
}
|
||||
|
||||
export type TableAccessCheck = TableAccessResult | TableAccessDenied
|
||||
|
||||
export type AccessResult = { ok: true; table: TableDefinition } | { ok: false; status: 404 | 403 }
|
||||
|
||||
export interface ApiErrorResponse {
|
||||
error: string
|
||||
details?: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user has read access to a table.
|
||||
* Read access is granted if:
|
||||
* 1. User created the table, OR
|
||||
* 2. User has any permission on the table's workspace (read, write, or admin)
|
||||
*
|
||||
* Follows the same pattern as Knowledge Base access checks.
|
||||
*/
|
||||
export async function checkTableAccess(tableId: string, userId: string): Promise<TableAccessCheck> {
|
||||
const table = await getTableById(tableId)
|
||||
|
||||
if (!table) {
|
||||
return { hasAccess: false, notFound: true }
|
||||
}
|
||||
|
||||
// Case 1: User created the table
|
||||
if (table.createdBy === userId) {
|
||||
return { hasAccess: true, table }
|
||||
}
|
||||
|
||||
// Case 2: Table belongs to a workspace the user has permissions for
|
||||
const userPermission = await getUserEntityPermissions(userId, 'workspace', table.workspaceId)
|
||||
if (userPermission !== null) {
|
||||
return { hasAccess: true, table }
|
||||
}
|
||||
|
||||
return { hasAccess: false, reason: 'User does not have access to this table' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user has write access to a table.
|
||||
* Write access is granted if:
|
||||
* 1. User created the table, OR
|
||||
* 2. User has write or admin permissions on the table's workspace
|
||||
*
|
||||
* Follows the same pattern as Knowledge Base write access checks.
|
||||
*/
|
||||
export async function checkTableWriteAccess(
|
||||
tableId: string,
|
||||
userId: string
|
||||
): Promise<TableAccessCheck> {
|
||||
const table = await getTableById(tableId)
|
||||
|
||||
if (!table) {
|
||||
return { hasAccess: false, notFound: true }
|
||||
}
|
||||
|
||||
// Case 1: User created the table
|
||||
if (table.createdBy === userId) {
|
||||
return { hasAccess: true, table }
|
||||
}
|
||||
|
||||
// Case 2: Table belongs to a workspace and user has write/admin permissions
|
||||
const userPermission = await getUserEntityPermissions(userId, 'workspace', table.workspaceId)
|
||||
if (userPermission === 'write' || userPermission === 'admin') {
|
||||
return { hasAccess: true, table }
|
||||
}
|
||||
|
||||
return { hasAccess: false, reason: 'User does not have write access to this table' }
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use checkTableAccess or checkTableWriteAccess instead.
|
||||
* Legacy access check function for backwards compatibility.
|
||||
*/
|
||||
export async function checkAccess(
|
||||
tableId: string,
|
||||
userId: string,
|
||||
level: 'read' | 'write' | 'admin' = 'read'
|
||||
): Promise<AccessResult> {
|
||||
const table = await getTableById(tableId)
|
||||
|
||||
if (!table) {
|
||||
return { ok: false, status: 404 }
|
||||
}
|
||||
|
||||
if (table.createdBy === userId) {
|
||||
return { ok: true, table }
|
||||
}
|
||||
|
||||
const permission = await getUserEntityPermissions(userId, 'workspace', table.workspaceId)
|
||||
const hasAccess =
|
||||
permission !== null &&
|
||||
(level === 'read' ||
|
||||
(level === 'write' && (permission === 'write' || permission === 'admin')) ||
|
||||
(level === 'admin' && permission === 'admin'))
|
||||
|
||||
return hasAccess ? { ok: true, table } : { ok: false, status: 403 }
|
||||
}
|
||||
|
||||
export function accessError(
|
||||
result: { ok: false; status: 404 | 403 },
|
||||
requestId: string,
|
||||
context?: string
|
||||
): NextResponse {
|
||||
const message = result.status === 404 ? 'Table not found' : 'Access denied'
|
||||
logger.warn(`[${requestId}] ${message}${context ? `: ${context}` : ''}`)
|
||||
return NextResponse.json({ error: message }, { status: result.status })
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a TableAccessDenied result to an appropriate HTTP response.
|
||||
* Use with checkTableAccess or checkTableWriteAccess.
|
||||
*/
|
||||
export function tableAccessError(
|
||||
result: TableAccessDenied,
|
||||
requestId: string,
|
||||
context?: string
|
||||
): NextResponse {
|
||||
const status = result.notFound ? 404 : 403
|
||||
const message = result.notFound ? 'Table not found' : (result.reason ?? 'Access denied')
|
||||
logger.warn(`[${requestId}] ${message}${context ? `: ${context}` : ''}`)
|
||||
return NextResponse.json({ error: message }, { status })
|
||||
}
|
||||
|
||||
export async function verifyTableWorkspace(tableId: string, workspaceId: string): Promise<boolean> {
|
||||
const table = await getTableById(tableId)
|
||||
return table?.workspaceId === workspaceId
|
||||
}
|
||||
|
||||
export function errorResponse(
|
||||
message: string,
|
||||
status: number,
|
||||
details?: unknown
|
||||
): NextResponse<ApiErrorResponse> {
|
||||
const body: ApiErrorResponse = { error: message }
|
||||
if (details !== undefined) {
|
||||
body.details = details
|
||||
}
|
||||
return NextResponse.json(body, { status })
|
||||
}
|
||||
|
||||
export function badRequestResponse(message: string, details?: unknown) {
|
||||
return errorResponse(message, 400, details)
|
||||
}
|
||||
|
||||
export function unauthorizedResponse(message = 'Authentication required') {
|
||||
return errorResponse(message, 401)
|
||||
}
|
||||
|
||||
export function forbiddenResponse(message = 'Access denied') {
|
||||
return errorResponse(message, 403)
|
||||
}
|
||||
|
||||
export function notFoundResponse(message = 'Resource not found') {
|
||||
return errorResponse(message, 404)
|
||||
}
|
||||
|
||||
export function serverErrorResponse(message = 'Internal server error') {
|
||||
return errorResponse(message, 500)
|
||||
}
|
||||
|
||||
export function normalizeColumn(col: ColumnDefinition): ColumnDefinition {
|
||||
return {
|
||||
name: col.name,
|
||||
type: col.type,
|
||||
required: col.required ?? false,
|
||||
unique: col.unique ?? false,
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { createA2AClient } from '@/lib/a2a/utils'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { validateExternalUrl } from '@/lib/core/security/input-validation'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
@@ -39,6 +40,18 @@ export async function POST(request: NextRequest) {
|
||||
const body = await request.json()
|
||||
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`, {
|
||||
agentUrl: validatedData.agentUrl,
|
||||
taskId: validatedData.taskId,
|
||||
|
||||
@@ -181,7 +181,7 @@ describe('Custom Tools API Routes', () => {
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/auth/hybrid', () => ({
|
||||
checkHybridAuth: vi.fn().mockResolvedValue({
|
||||
checkSessionOrInternalAuth: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
userId: 'user-123',
|
||||
authType: 'session',
|
||||
@@ -254,7 +254,7 @@ describe('Custom Tools API Routes', () => {
|
||||
)
|
||||
|
||||
vi.doMock('@/lib/auth/hybrid', () => ({
|
||||
checkHybridAuth: vi.fn().mockResolvedValue({
|
||||
checkSessionOrInternalAuth: vi.fn().mockResolvedValue({
|
||||
success: false,
|
||||
error: 'Unauthorized',
|
||||
}),
|
||||
@@ -304,7 +304,7 @@ describe('Custom Tools API Routes', () => {
|
||||
describe('POST /api/tools/custom', () => {
|
||||
it('should reject unauthorized requests', async () => {
|
||||
vi.doMock('@/lib/auth/hybrid', () => ({
|
||||
checkHybridAuth: vi.fn().mockResolvedValue({
|
||||
checkSessionOrInternalAuth: vi.fn().mockResolvedValue({
|
||||
success: false,
|
||||
error: 'Unauthorized',
|
||||
}),
|
||||
@@ -390,7 +390,7 @@ describe('Custom Tools API Routes', () => {
|
||||
|
||||
it('should prevent unauthorized deletion of user-scoped tool', async () => {
|
||||
vi.doMock('@/lib/auth/hybrid', () => ({
|
||||
checkHybridAuth: vi.fn().mockResolvedValue({
|
||||
checkSessionOrInternalAuth: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
userId: 'user-456',
|
||||
authType: 'session',
|
||||
@@ -413,7 +413,7 @@ describe('Custom Tools API Routes', () => {
|
||||
|
||||
it('should reject unauthorized requests', async () => {
|
||||
vi.doMock('@/lib/auth/hybrid', () => ({
|
||||
checkHybridAuth: vi.fn().mockResolvedValue({
|
||||
checkSessionOrInternalAuth: vi.fn().mockResolvedValue({
|
||||
success: false,
|
||||
error: 'Unauthorized',
|
||||
}),
|
||||
|
||||
@@ -4,7 +4,7 @@ import { createLogger } from '@sim/logger'
|
||||
import { and, desc, eq, isNull, or } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { upsertCustomTools } from '@/lib/workflows/custom-tools/operations'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
@@ -42,8 +42,8 @@ export async function GET(request: NextRequest) {
|
||||
const workflowId = searchParams.get('workflowId')
|
||||
|
||||
try {
|
||||
// Use hybrid auth to support session, API key, and internal JWT
|
||||
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
// Use session/internal auth to support session and internal JWT (no API key access)
|
||||
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized custom tools access attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
@@ -69,8 +69,8 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
// Check workspace permissions
|
||||
// For internal JWT with workflowId: checkHybridAuth already resolved userId from workflow owner
|
||||
// For session/API key: verify user has access to the workspace
|
||||
// For internal JWT with workflowId: checkSessionOrInternalAuth already resolved userId from workflow owner
|
||||
// For session: verify user has access to the workspace
|
||||
// For legacy (no workspaceId): skip workspace check, rely on userId match
|
||||
if (resolvedWorkspaceId && !(authResult.authType === 'internal_jwt' && workflowId)) {
|
||||
const userPermission = await getUserEntityPermissions(
|
||||
@@ -116,8 +116,8 @@ export async function POST(req: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
// Use hybrid auth (though this endpoint is only called from UI)
|
||||
const authResult = await checkHybridAuth(req, { requireWorkflowId: false })
|
||||
// Use session/internal auth (no API key access)
|
||||
const authResult = await checkSessionOrInternalAuth(req, { requireWorkflowId: false })
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized custom tools update attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
@@ -193,8 +193,8 @@ export async function DELETE(request: NextRequest) {
|
||||
}
|
||||
|
||||
try {
|
||||
// Use hybrid auth (though this endpoint is only called from UI)
|
||||
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
// Use session/internal auth (no API key access)
|
||||
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized custom tool deletion attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { validateNumericId } from '@/lib/core/security/input-validation'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
|
||||
@@ -22,7 +22,7 @@ export async function POST(request: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
|
||||
if (!authResult.success) {
|
||||
logger.warn(`[${requestId}] Unauthorized Discord send attempt: ${authResult.error}`)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
|
||||
@@ -21,7 +21,7 @@ export async function POST(request: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
|
||||
if (!authResult.success) {
|
||||
logger.warn(`[${requestId}] Unauthorized Gmail add label attempt: ${authResult.error}`)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
@@ -19,7 +19,7 @@ export async function POST(request: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
|
||||
if (!authResult.success) {
|
||||
logger.warn(`[${requestId}] Unauthorized Gmail archive attempt: ${authResult.error}`)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
@@ -19,7 +19,7 @@ export async function POST(request: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
|
||||
if (!authResult.success) {
|
||||
logger.warn(`[${requestId}] Unauthorized Gmail delete attempt: ${authResult.error}`)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
|
||||
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
|
||||
@@ -35,7 +35,7 @@ export async function POST(request: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
|
||||
if (!authResult.success) {
|
||||
logger.warn(`[${requestId}] Unauthorized Gmail draft attempt: ${authResult.error}`)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
@@ -19,7 +19,7 @@ export async function POST(request: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
|
||||
if (!authResult.success) {
|
||||
logger.warn(`[${requestId}] Unauthorized Gmail mark read attempt: ${authResult.error}`)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
@@ -19,7 +19,7 @@ export async function POST(request: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
|
||||
if (!authResult.success) {
|
||||
logger.warn(`[${requestId}] Unauthorized Gmail mark unread attempt: ${authResult.error}`)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
@@ -21,7 +21,7 @@ export async function POST(request: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
|
||||
if (!authResult.success) {
|
||||
logger.warn(`[${requestId}] Unauthorized Gmail move attempt: ${authResult.error}`)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
|
||||
@@ -21,7 +21,7 @@ export async function POST(request: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
|
||||
if (!authResult.success) {
|
||||
logger.warn(`[${requestId}] Unauthorized Gmail remove label attempt: ${authResult.error}`)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
|
||||
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
|
||||
@@ -35,7 +35,7 @@ export async function POST(request: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
|
||||
if (!authResult.success) {
|
||||
logger.warn(`[${requestId}] Unauthorized Gmail send attempt: ${authResult.error}`)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
@@ -19,7 +19,7 @@ export async function POST(request: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
|
||||
if (!authResult.success) {
|
||||
logger.warn(`[${requestId}] Unauthorized Gmail unarchive attempt: ${authResult.error}`)
|
||||
|
||||