v0.6.52: data retention, docs updates, slack manifest generator, security hardening, contact page, 404 page, access control, SES, SNS
@@ -1,7 +1,10 @@
|
||||
# Global Standards
|
||||
|
||||
## Logging
|
||||
Import `createLogger` from `sim/logger`. Use `logger.info`, `logger.warn`, `logger.error` instead of `console.log`.
|
||||
Import `createLogger` from `@sim/logger`. Use `logger.info`, `logger.warn`, `logger.error` instead of `console.log`. Inside API routes wrapped with `withRouteHandler`, loggers automatically include the request ID.
|
||||
|
||||
## API Route Handlers
|
||||
All API route handlers must be wrapped with `withRouteHandler` from `@/lib/core/utils/with-route-handler`. Never export a bare `async function GET/POST/...` — always use `export const METHOD = withRouteHandler(...)`.
|
||||
|
||||
## Comments
|
||||
Use TSDoc for documentation. No `====` separators. No non-TSDoc comments.
|
||||
|
||||
@@ -217,13 +217,20 @@ it('reads a row', async () => {
|
||||
```
|
||||
|
||||
**Default chains supported:**
|
||||
- `select()/selectDistinct()/selectDistinctOn() → from() → where()/innerJoin()/leftJoin() → where() → limit()/orderBy()/returning()/groupBy()`
|
||||
- `select()/selectDistinct()/selectDistinctOn() → from() → where()/innerJoin()/leftJoin() → where() → limit()/orderBy()/returning()/groupBy()/for()`
|
||||
- `insert() → values() → returning()/onConflictDoUpdate()/onConflictDoNothing()`
|
||||
- `update() → set() → where() → limit()/orderBy()/returning()`
|
||||
- `delete() → where() → limit()/orderBy()/returning()`
|
||||
- `update() → set() → where() → limit()/orderBy()/returning()/for()`
|
||||
- `delete() → where() → limit()/orderBy()/returning()/for()`
|
||||
- `db.execute()` resolves `[]`
|
||||
- `db.transaction(cb)` calls cb with `dbChainMock.db`
|
||||
|
||||
`.for('update')` (Postgres row-level locking) is supported on `where`
|
||||
builders. It returns a thenable with `.limit` / `.orderBy` / `.returning` /
|
||||
`.groupBy` attached, so both `await .where().for('update')` (terminal) and
|
||||
`await .where().for('update').limit(1)` (chained) work. Override the terminal
|
||||
result with `dbChainMockFns.for.mockResolvedValueOnce([...])`; for the chained
|
||||
form, mock the downstream terminal (e.g. `dbChainMockFns.limit.mockResolvedValueOnce([...])`).
|
||||
|
||||
All terminals default to `Promise.resolve([])`. Override per-test with `dbChainMockFns.<terminal>.mockResolvedValueOnce(...)`.
|
||||
|
||||
Use `resetDbChainMock()` in `beforeEach` only when tests replace wiring with `.mockReturnValue` / `.mockResolvedValue` (permanent). Tests using only `...Once` variants don't need it.
|
||||
|
||||
38
CLAUDE.md
@@ -4,7 +4,8 @@ You are a professional software engineer. All code must follow best practices: a
|
||||
|
||||
## Global Standards
|
||||
|
||||
- **Logging**: Import `createLogger` from `@sim/logger`. Use `logger.info`, `logger.warn`, `logger.error` instead of `console.log`
|
||||
- **Logging**: Import `createLogger` from `@sim/logger`. Use `logger.info`, `logger.warn`, `logger.error` instead of `console.log`. Inside API routes wrapped with `withRouteHandler`, loggers automatically include the request ID — no manual `withMetadata({ requestId })` needed
|
||||
- **API Route Handlers**: All API route handlers (`GET`, `POST`, `PUT`, `DELETE`, `PATCH`) must be wrapped with `withRouteHandler` from `@/lib/core/utils/with-route-handler`. This provides request ID tracking, automatic error logging for 4xx/5xx responses, and unhandled error catching. See "API Route Pattern" section below
|
||||
- **Comments**: Use TSDoc for documentation. No `====` separators. No non-TSDoc comments
|
||||
- **Styling**: Never update global styles. Keep all styling local to components
|
||||
- **ID Generation**: Never use `crypto.randomUUID()`, `nanoid`, or `uuid` package. Use `generateId()` (UUID v4) or `generateShortId()` (compact) from `@sim/utils/id`
|
||||
@@ -93,6 +94,41 @@ export function Component({ requiredProp, optionalProp = false }: ComponentProps
|
||||
|
||||
Extract when: 50+ lines, used in 2+ files, or has own state/logic. Keep inline when: < 10 lines, single use, purely presentational.
|
||||
|
||||
## API Route Pattern
|
||||
|
||||
Every API route handler must be wrapped with `withRouteHandler`. This sets up `AsyncLocalStorage`-based request context so all loggers in the request lifecycle automatically include the request ID.
|
||||
|
||||
```typescript
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
|
||||
const logger = createLogger('MyAPI')
|
||||
|
||||
// Simple route
|
||||
export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
logger.info('Handling request') // automatically includes {requestId=...}
|
||||
return NextResponse.json({ ok: true })
|
||||
})
|
||||
|
||||
// Route with params
|
||||
export const DELETE = withRouteHandler(async (
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) => {
|
||||
const { id } = await params
|
||||
return NextResponse.json({ deleted: id })
|
||||
})
|
||||
|
||||
// Composing with other middleware (withRouteHandler wraps the outermost layer)
|
||||
export const POST = withRouteHandler(withAdminAuth(async (request) => {
|
||||
return NextResponse.json({ ok: true })
|
||||
}))
|
||||
```
|
||||
|
||||
Never export a bare `async function GET/POST/...` — always use `export const METHOD = withRouteHandler(...)`.
|
||||
|
||||
## Hooks
|
||||
|
||||
```typescript
|
||||
|
||||
@@ -4681,6 +4681,17 @@ export function IAMIcon(props: SVGProps<SVGSVGElement>) {
|
||||
)
|
||||
}
|
||||
|
||||
export function IdentityCenterIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'>
|
||||
<path
|
||||
d='M13.694,14.8194562 C14.376,14.1374562 14.376,13.0294562 13.694,12.3474562 C13.353,12.0074562 12.906,11.8374562 12.459,11.8374562 C12.01,11.8374562 11.563,12.0074562 11.222,12.3474562 C10.542,13.0284562 10.542,14.1384562 11.222,14.8194562 C11.905,15.5014562 13.013,15.4994562 13.694,14.8194562 M14.718,15.1374562 L18.703,19.1204562 L17.996,19.8274562 L16.868,18.6994562 L15.793,19.7754562 L15.086,19.0684562 L16.161,17.9924562 L14.011,15.8444562 C13.545,16.1654562 13.003,16.3294562 12.458,16.3294562 C11.755,16.3294562 11.051,16.0624562 10.515,15.5264562 C9.445,14.4554562 9.445,12.7124562 10.515,11.6404562 C11.586,10.5714562 13.329,10.5694562 14.401,11.6404562 C15.351,12.5904562 15.455,14.0674562 14.718,15.1374562 M20,12.1014562 C20,14.1684562 18.505,15.0934562 17.023,15.0934562 L17.023,14.0934562 C17.487,14.0934562 19,13.9494562 19,12.1014562 C19,11.0044562 18.353,10.3894562 16.905,10.1084562 C16.68,10.0654562 16.514,9.87545615 16.501,9.64845615 C16.446,8.74445615 15.987,8.11245615 15.384,8.11245615 C15.084,8.11245615 14.854,8.24245615 14.616,8.54645615 C14.506,8.68845615 14.324,8.75945615 14.147,8.73245615 C13.968,8.70545615 13.818,8.58445615 13.755,8.41445615 C13.577,7.94345615 13.211,7.43345615 12.723,6.97745615 C12.231,6.50945615 10.883,5.50745615 8.972,6.27345615 C7.885,6.70545615 7.034,7.94945615 7.034,9.10745615 C7.034,9.23545615 7.043,9.36245615 7.058,9.48845615 C7.061,9.50945615 7.062,9.53045615 7.062,9.55145615 C7.062,9.79945615 6.882,10.0064562 6.645,10.0464562 C5.886,10.2394562 5,10.7454562 5,12.0554562 L5.005,12.2104562 C5.069,13.3254562 6.252,13.9954562 7.358,13.9984562 L8,13.9984562 L8,14.9984562 L7.357,14.9984562 C5.536,14.9944562 4.095,13.8194562 4.006,12.2644562 C4.003,12.1944562 4,12.1244562 4,12.0554562 C4,10.6944562 4.752,9.64845615 6.035,9.18845615 C6.034,9.16145615 6.034,9.13445615 6.034,9.10745615 C6.034,7.54345615 7.138,5.92545615 8.602,5.34345615 C10.298,4.66545615 12.095,5.00345615 13.409,6.24945615 C13.706,6.52745615 14.076,6.92645615 14.372,7.41345615 C14.673,7.21245615 15.008,7.11245615 15.384,7.11245615 C16.257,7.11245615 17.231,7.77145615 17.458,9.20745615 C19.145,9.63245615 20,10.6054562 20,12.1014562'
|
||||
fill='#FFFFFF'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function STSIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} viewBox='0 0 80 80' xmlns='http://www.w3.org/2000/svg'>
|
||||
@@ -4699,6 +4710,24 @@ export function STSIcon(props: SVGProps<SVGSVGElement>) {
|
||||
)
|
||||
}
|
||||
|
||||
export function SESIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} viewBox='0 0 80 80' xmlns='http://www.w3.org/2000/svg'>
|
||||
<defs>
|
||||
<linearGradient x1='0%' y1='100%' x2='100%' y2='0%' id='sesGradient'>
|
||||
<stop stopColor='#BD0816' offset='0%' />
|
||||
<stop stopColor='#FF5252' offset='100%' />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect fill='url(#sesGradient)' width='80' height='80' />
|
||||
<path
|
||||
d='M57,60.999875 C57,59.373846 55.626,57.9998214 54,57.9998214 C52.374,57.9998214 51,59.373846 51,60.999875 C51,62.625904 52.374,63.9999286 54,63.9999286 C55.626,63.9999286 57,62.625904 57,60.999875 L57,60.999875 Z M40,59.9998571 C38.374,59.9998571 37,61.3738817 37,62.9999107 C37,64.6259397 38.374,65.9999643 40,65.9999643 C41.626,65.9999643 43,64.6259397 43,62.9999107 C43,61.3738817 41.626,59.9998571 40,59.9998571 L40,59.9998571 Z M26,57.9998214 C24.374,57.9998214 23,59.373846 23,60.999875 C23,62.625904 24.374,63.9999286 26,63.9999286 C27.626,63.9999286 29,62.625904 29,60.999875 C29,59.373846 27.626,57.9998214 26,57.9998214 L26,57.9998214 Z M28.605,42.9995536 L51.395,42.9995536 L43.739,36.1104305 L40.649,38.7584778 C40.463,38.9194807 40.23,38.9994821 39.999,38.9994821 C39.768,38.9994821 39.535,38.9194807 39.349,38.7584778 L36.26,36.1104305 L28.605,42.9995536 Z M27,28.1732888 L27,41.7545313 L34.729,34.7984071 L27,28.1732888 Z M51.297,26.9992678 L28.703,26.9992678 L39.999,36.6824408 L51.297,26.9992678 Z M53,41.7545313 L53,28.1732888 L45.271,34.7974071 L53,41.7545313 Z M59,60.999875 C59,63.7099234 56.71,65.9999643 54,65.9999643 C51.29,65.9999643 49,63.7099234 49,60.999875 C49,58.6308327 50.75,56.5837961 53,56.1057876 L53,52.9997321 L41,52.9997321 L41,58.1058233 C43.25,58.5838319 45,60.6308684 45,62.9999107 C45,65.7099591 42.71,68 40,68 C37.29,68 35,65.7099591 35,62.9999107 C35,60.6308684 36.75,58.5838319 39,58.1058233 L39,52.9997321 L27,52.9997321 L27,56.1057876 C29.25,56.5837961 31,58.6308327 31,60.999875 C31,63.7099234 28.71,65.9999643 26,65.9999643 C23.29,65.9999643 21,63.7099234 21,60.999875 C21,58.6308327 22.75,56.5837961 25,56.1057876 L25,51.9997143 C25,51.4477044 25.447,50.9996964 26,50.9996964 L39,50.9996964 L39,44.9995893 L26,44.9995893 C25.447,44.9995893 25,44.5515813 25,43.9995714 L25,25.99925 C25,25.4472401 25.447,24.9992321 26,24.9992321 L54,24.9992321 C54.553,24.9992321 55,25.4472401 55,25.99925 L55,43.9995714 C55,44.5515813 54.553,44.9995893 54,44.9995893 L41,44.9995893 L41,50.9996964 L54,50.9996964 C54.553,50.9996964 55,51.4477044 55,51.9997143 L55,56.1057876 C57.25,56.5837961 59,58.6308327 59,60.999875 L59,60.999875 Z M68,39.9995 C68,45.9066055 66.177,51.5597064 62.727,56.3447919 L61.104,55.174771 C64.307,50.7316916 66,45.4845979 66,39.9995 C66,25.664244 54.337,14.0000357 40.001,14.0000357 C25.664,14.0000357 14,25.664244 14,39.9995 C14,45.4845979 15.693,50.7316916 18.896,55.174771 L17.273,56.3447919 C13.823,51.5597064 12,45.9066055 12,39.9995 C12,24.5612243 24.561,12 39.999,12 C55.438,12 68,24.5612243 68,39.9995 L68,39.9995 Z'
|
||||
fill='#FFFFFF'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function SecretsManagerIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} viewBox='0 0 80 80' xmlns='http://www.w3.org/2000/svg'>
|
||||
|
||||
@@ -91,6 +91,7 @@ import {
|
||||
HuggingFaceIcon,
|
||||
HunterIOIcon,
|
||||
IAMIcon,
|
||||
IdentityCenterIcon,
|
||||
ImageIcon,
|
||||
IncidentioIcon,
|
||||
InfisicalIcon,
|
||||
@@ -152,6 +153,7 @@ import {
|
||||
RootlyIcon,
|
||||
S3Icon,
|
||||
SalesforceIcon,
|
||||
SESIcon,
|
||||
SearchIcon,
|
||||
SecretsManagerIcon,
|
||||
SendgridIcon,
|
||||
@@ -294,6 +296,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
huggingface: HuggingFaceIcon,
|
||||
hunter: HunterIOIcon,
|
||||
iam: IAMIcon,
|
||||
identity_center: IdentityCenterIcon,
|
||||
image_generator: ImageIcon,
|
||||
imap: MailServerIcon,
|
||||
incidentio: IncidentioIcon,
|
||||
@@ -370,6 +373,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
sentry: SentryIcon,
|
||||
serper: SerperIcon,
|
||||
servicenow: ServiceNowIcon,
|
||||
ses: SESIcon,
|
||||
sftp: SftpIcon,
|
||||
sharepoint: MicrosoftSharepointIcon,
|
||||
shopify: ShopifyIcon,
|
||||
|
||||
216
apps/docs/content/docs/en/enterprise/access-control.mdx
Normal file
@@ -0,0 +1,216 @@
|
||||
---
|
||||
title: Access Control
|
||||
description: Restrict which models, blocks, and platform features each group of users can access
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout'
|
||||
import { FAQ } from '@/components/ui/faq'
|
||||
import { Image } from '@/components/ui/image'
|
||||
|
||||
Access Control lets workspace admins define permission groups that restrict what each set of workspace members can do — which AI model providers they can use, which workflow blocks they can place, and which platform features are visible to them. Permission groups are scoped to a single workspace: a user can be in different groups (or no group) in different workspaces. Restrictions are enforced both in the workflow executor and in Mothership, based on the workflow's workspace.
|
||||
|
||||
---
|
||||
|
||||
## How it works
|
||||
|
||||
Access control is built around **permission groups**. Each group belongs to a specific workspace and has a name, an optional description, and a configuration that defines what its members can and cannot do. A user can belong to at most one permission group **per workspace**, but can belong to different groups in different workspaces.
|
||||
|
||||
When a user runs a workflow or uses Mothership, Sim reads their group's configuration and applies it:
|
||||
|
||||
- **In the executor:** If a workflow uses a disallowed block type or model provider, execution halts immediately with an error. This applies to both manual runs and scheduled or API-triggered deployments.
|
||||
- **In Mothership:** Disallowed blocks are filtered out of the block list so they cannot be added to a workflow. Disallowed tool types (MCP, custom tools, skills) are skipped if Mothership attempts to use them.
|
||||
|
||||
---
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Open Access Control settings
|
||||
|
||||
Go to **Settings → Enterprise → Access Control** in the workspace you want to manage. Each workspace has its own set of permission groups.
|
||||
|
||||
<Image src="/static/enterprise/access-control-groups.png" alt="Access Control settings showing a list of permission groups: Contractors, Sales, Engineering, and Marketing, each with Details and Delete actions" width={900} height={500} />
|
||||
|
||||
### 2. Create a permission group
|
||||
|
||||
Click **+ Create** and enter a name (required) and optional description. You can also enable **Auto-add new members** — when active, any new member who joins this workspace is automatically added to this group. Only one group per workspace can have this setting enabled at a time.
|
||||
|
||||
### 3. Configure permissions
|
||||
|
||||
Click **Details** on a group, then open **Configure Permissions**. There are three tabs.
|
||||
|
||||
#### Model Providers
|
||||
|
||||
Controls which AI model providers members of this group can use.
|
||||
|
||||
<Image src="/static/enterprise/access-control-model-providers.png" alt="Model Providers tab showing a grid of AI providers including Ollama, vLLM, OpenAI, Anthropic, Google, Azure OpenAI, and others with checkboxes to allow or restrict access" width={900} height={500} /> The list shows all providers available in Sim.
|
||||
|
||||
- **All checked (default):** All providers are allowed.
|
||||
- **Subset checked:** Only the selected providers are allowed. Any workflow block or agent using a provider not on the list will fail at execution time.
|
||||
|
||||
#### Blocks
|
||||
|
||||
Controls which workflow blocks members can place and execute.
|
||||
|
||||
<Image src="/static/enterprise/access-control-blocks.png" alt="Blocks tab showing Core Blocks (Agent, API, Condition, Function, Knowledge, etc.) and Tools (integrations like 1Password, A2A, Ahrefs, Airtable, and more) with checkboxes to allow or restrict each" width={900} height={500} /> Blocks are split into two sections: **Core Blocks** (Agent, API, Condition, Function, etc.) and **Tools** (all integration blocks).
|
||||
|
||||
- **All checked (default):** All blocks are allowed.
|
||||
- **Subset checked:** Only the selected blocks are allowed. Workflows that already contain a disallowed block will fail when run — they are not automatically modified.
|
||||
|
||||
<Callout type="info">
|
||||
The `start_trigger` block (the entry point of every workflow) is always allowed and cannot be restricted.
|
||||
</Callout>
|
||||
|
||||
#### Platform
|
||||
|
||||
Controls visibility of platform features and modules.
|
||||
|
||||
<Image src="/static/enterprise/access-control-platform.png" alt="Platform tab showing feature toggles grouped by category: Sidebar (Knowledge Base, Tables, Templates), Workflow Panel (Copilot), Settings Tabs, Tools, Deploy Tabs, Features, Logs, and Collaboration" width={900} height={500} /> Each checkbox maps to a specific feature; checking it hides or disables that feature for group members.
|
||||
|
||||
**Sidebar**
|
||||
|
||||
| Feature | Effect when checked |
|
||||
|---------|-------------------|
|
||||
| Knowledge Base | Hides the Knowledge Base section from the sidebar |
|
||||
| Tables | Hides the Tables section from the sidebar |
|
||||
| Templates | Hides the Templates section from the sidebar |
|
||||
|
||||
**Workflow Panel**
|
||||
|
||||
| Feature | Effect when checked |
|
||||
|---------|-------------------|
|
||||
| Copilot | Hides the Copilot panel inside the workflow editor |
|
||||
|
||||
**Settings Tabs**
|
||||
|
||||
| Feature | Effect when checked |
|
||||
|---------|-------------------|
|
||||
| Integrations | Hides the Integrations tab in Settings |
|
||||
| Secrets | Hides the Secrets tab in Settings |
|
||||
| API Keys | Hides the Sim Keys tab in Settings |
|
||||
| Files | Hides the Files tab in Settings |
|
||||
|
||||
**Tools**
|
||||
|
||||
| Feature | Effect when checked |
|
||||
|---------|-------------------|
|
||||
| MCP Tools | Disables the use of MCP tools in workflows and agents |
|
||||
| Custom Tools | Disables the use of custom tools in workflows and agents |
|
||||
| Skills | Disables the use of Sim Skills in workflows and agents |
|
||||
|
||||
**Deploy Tabs**
|
||||
|
||||
| Feature | Effect when checked |
|
||||
|---------|-------------------|
|
||||
| API | Hides the API deployment tab |
|
||||
| MCP | Hides the MCP deployment tab |
|
||||
| A2A | Hides the A2A deployment tab |
|
||||
| Chat | Hides the Chat deployment tab |
|
||||
| Template | Hides the Template deployment tab |
|
||||
|
||||
**Features**
|
||||
|
||||
| Feature | Effect when checked |
|
||||
|---------|-------------------|
|
||||
| Sim Mailer | Hides the Sim Mailer (Inbox) feature |
|
||||
| Public API | Disables public API access for deployed workflows |
|
||||
|
||||
**Logs**
|
||||
|
||||
| Feature | Effect when checked |
|
||||
|---------|-------------------|
|
||||
| Trace Spans | Hides trace span details in execution logs |
|
||||
|
||||
**Collaboration**
|
||||
|
||||
| Feature | Effect when checked |
|
||||
|---------|-------------------|
|
||||
| Invitations | Disables the ability to invite new members to the workspace |
|
||||
|
||||
### 4. Add members
|
||||
|
||||
Open the group's **Details** view and add members by searching for users by name or email. Only users who already have workspace-level access can be added. A user can only belong to one group per workspace — adding a user to a new group within the same workspace removes them from their current group for that workspace.
|
||||
|
||||
---
|
||||
|
||||
## Enforcement
|
||||
|
||||
### Workflow execution
|
||||
|
||||
Restrictions are enforced at the point of execution, not at save time. If a group's configuration changes after a workflow is built:
|
||||
|
||||
- **Block restrictions:** Any workflow run that reaches a disallowed block halts immediately with an error. The workflow is not modified — only execution is blocked.
|
||||
- **Model provider restrictions:** Any block or agent that uses a disallowed provider halts immediately with an error.
|
||||
- **Tool restrictions (MCP, custom tools, skills):** Agents that use a disallowed tool type halt immediately with an error.
|
||||
|
||||
This applies regardless of how the workflow is triggered — manually, via API, via schedule, or via webhook.
|
||||
|
||||
### Mothership
|
||||
|
||||
When a user opens Mothership, their permission group is read before any block or tool suggestions are made:
|
||||
|
||||
- Blocks not in the allowed list are filtered out of the block picker entirely — they do not appear as options.
|
||||
- If Mothership generates a workflow step that would use a disallowed tool (MCP, custom, or skills), that step is skipped and the reason is noted.
|
||||
|
||||
---
|
||||
|
||||
## User membership rules
|
||||
|
||||
- A user can belong to **at most one** permission group **per workspace**, but may be in different groups across different workspaces.
|
||||
- Moving a user to a new group within a workspace automatically removes them from their previous group in that workspace.
|
||||
- Users not assigned to any group in a workspace have no restrictions applied in that workspace (all blocks, providers, and features are available to them there).
|
||||
- If **Auto-add new members** is enabled on a group, new members of that workspace are automatically placed in the group. Only one group per workspace can have this setting active.
|
||||
|
||||
---
|
||||
|
||||
<FAQ items={[
|
||||
{
|
||||
question: "Who can create and manage permission groups?",
|
||||
answer: "Any workspace admin on an Enterprise-entitled workspace can create, edit, and delete permission groups for that workspace. On Sim Cloud, the workspace's billed account must be on the Enterprise plan; on self-hosted deployments you can enable it via ACCESS_CONTROL_ENABLED."
|
||||
},
|
||||
{
|
||||
question: "What happens to a workflow that was built before a block was restricted?",
|
||||
answer: "The workflow is not modified — it still exists and can be edited. However, any run that reaches a disallowed block will halt immediately with an error. The block must be removed or the user's group configuration must be updated before the workflow can run successfully."
|
||||
},
|
||||
{
|
||||
question: "Can a user be in multiple permission groups?",
|
||||
answer: "A user can belong to at most one permission group per workspace, but can belong to different groups in different workspaces. Adding a user to a new group within the same workspace automatically removes them from their previous group in that workspace."
|
||||
},
|
||||
{
|
||||
question: "What does a user see if they have no permission group assigned in a workspace?",
|
||||
answer: "Users with no group in a given workspace have no restrictions in that workspace. All blocks, model providers, and platform features are fully available to them there. Restrictions only apply in the specific workspaces where they are assigned to a group."
|
||||
},
|
||||
{
|
||||
question: "Does Mothership respect the same restrictions as the executor?",
|
||||
answer: "Yes. Mothership reads the user's permission group for the active workspace before suggesting blocks or tools. Disallowed blocks are filtered out of the block picker, and disallowed tool types are skipped during workflow generation."
|
||||
},
|
||||
{
|
||||
question: "Can I restrict access to specific workflows or workspaces?",
|
||||
answer: "Access Control operates at the feature and block level within a workspace. To restrict who can access the workspace itself, use workspace invitations and permissions. To apply different restrictions to different workflows, put them in different workspaces with distinct permission groups."
|
||||
},
|
||||
{
|
||||
question: "What is Auto-add new members?",
|
||||
answer: "When a group has Auto-add new members enabled, any new member who joins the workspace is automatically added to that group. Only one group per workspace can have this setting enabled at a time."
|
||||
}
|
||||
]} />
|
||||
|
||||
---
|
||||
|
||||
## Self-hosted setup
|
||||
|
||||
Self-hosted deployments use environment variables instead of the billing/plan check.
|
||||
|
||||
### Environment variables
|
||||
|
||||
```bash
|
||||
ACCESS_CONTROL_ENABLED=true
|
||||
NEXT_PUBLIC_ACCESS_CONTROL_ENABLED=true
|
||||
```
|
||||
|
||||
You can also set a server-level block allowlist using the `ALLOWED_INTEGRATIONS` environment variable. This is applied as an additional constraint on top of any permission group configuration — a block must be allowed by both the environment allowlist and the user's group to be usable.
|
||||
|
||||
```bash
|
||||
# Only these block types are available across the entire instance
|
||||
ALLOWED_INTEGRATIONS=slack,gmail,agent,function,condition
|
||||
```
|
||||
|
||||
Once enabled, permission groups are managed through **Settings → Enterprise → Access Control** the same way as Sim Cloud.
|
||||
146
apps/docs/content/docs/en/enterprise/audit-logs.mdx
Normal file
@@ -0,0 +1,146 @@
|
||||
---
|
||||
title: Audit Logs
|
||||
description: Track every action taken across your organization's workspaces
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout'
|
||||
import { FAQ } from '@/components/ui/faq'
|
||||
import { Image } from '@/components/ui/image'
|
||||
|
||||
Audit logs give your organization a tamper-evident record of every significant action taken across workspaces — who did what, when, and on which resource. Use them for security reviews, compliance investigations, and incident response.
|
||||
|
||||
---
|
||||
|
||||
## Viewing audit logs
|
||||
|
||||
### In the UI
|
||||
|
||||
Go to **Settings → Enterprise → Audit Logs** in your workspace. Logs are displayed in a table with the following columns:
|
||||
|
||||
<Image src="/static/enterprise/audit-logs.png" alt="Audit Logs settings showing a table of events with columns for Timestamp, Event, Description, and Actor, along with search and filter controls" width={900} height={500} />
|
||||
|
||||
| Column | Description |
|
||||
|--------|-------------|
|
||||
| **Timestamp** | When the action occurred. |
|
||||
| **Event** | The action taken, e.g. `workflow.created`. |
|
||||
| **Description** | A human-readable summary of the action. |
|
||||
| **Actor** | The email address of the user who performed the action. |
|
||||
|
||||
Use the search bar, event type filter, and date range selector to narrow results.
|
||||
|
||||
### Via API
|
||||
|
||||
Audit logs are also accessible through the Sim API for integration with external SIEM or log management tools.
|
||||
|
||||
```http
|
||||
GET /api/v1/audit-logs
|
||||
Authorization: Bearer <api-key>
|
||||
```
|
||||
|
||||
**Query parameters:**
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `action` | string | Filter by event type (e.g. `workflow.created`) |
|
||||
| `resourceType` | string | Filter by resource type (e.g. `workflow`) |
|
||||
| `resourceId` | string | Filter by a specific resource ID |
|
||||
| `workspaceId` | string | Filter by workspace |
|
||||
| `actorId` | string | Filter by user ID (must be an org member) |
|
||||
| `startDate` | string | ISO 8601 date — return logs on or after this date |
|
||||
| `endDate` | string | ISO 8601 date — return logs on or before this date |
|
||||
| `includeDeparted` | boolean | Include logs from members who have since left the organization (default `false`) |
|
||||
| `limit` | number | Results per page (1–100, default 50) |
|
||||
| `cursor` | string | Opaque cursor for fetching the next page |
|
||||
|
||||
**Example response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": "abc123",
|
||||
"action": "workflow.created",
|
||||
"resourceType": "workflow",
|
||||
"resourceId": "wf_xyz",
|
||||
"resourceName": "Customer Onboarding",
|
||||
"description": "Created workflow \"Customer Onboarding\"",
|
||||
"actorId": "usr_abc",
|
||||
"actorName": "Alice Smith",
|
||||
"actorEmail": "alice@company.com",
|
||||
"workspaceId": "ws_def",
|
||||
"metadata": {},
|
||||
"createdAt": "2026-04-20T21:16:00.000Z"
|
||||
}
|
||||
],
|
||||
"nextCursor": "eyJpZCI6ImFiYzEyMyJ9"
|
||||
}
|
||||
```
|
||||
|
||||
Paginate by passing the `nextCursor` value as the `cursor` parameter in the next request. When `nextCursor` is absent, you have reached the last page.
|
||||
|
||||
<Callout type="info">
|
||||
The API accepts both personal and workspace-scoped API keys. Rate limits apply — the response includes `X-RateLimit-*` headers with your current limit and remaining quota.
|
||||
</Callout>
|
||||
|
||||
---
|
||||
|
||||
## Event types
|
||||
|
||||
Audit log events follow a `resource.action` naming pattern. The table below lists the main categories.
|
||||
|
||||
| Category | Example events |
|
||||
|----------|---------------|
|
||||
| **Workflows** | `workflow.created`, `workflow.deleted`, `workflow.deployed`, `workflow.locked` |
|
||||
| **Workspaces** | `workspace.created`, `workspace.updated`, `workspace.deleted` |
|
||||
| **Members** | `member.invited`, `member.removed`, `member.role_changed` |
|
||||
| **Permission groups** | `permission_group.created`, `permission_group.updated`, `permission_group.deleted` |
|
||||
| **Environments** | `environment.updated`, `environment.deleted` |
|
||||
| **Knowledge bases** | `knowledge_base.created`, `knowledge_base.deleted`, `connector.synced` |
|
||||
| **Tables** | `table.created`, `table.updated`, `table.deleted` |
|
||||
| **API keys** | `api_key.created`, `api_key.revoked` |
|
||||
| **Credentials** | `credential.created`, `credential.deleted`, `oauth.disconnected` |
|
||||
| **Organization** | `organization.updated`, `org_member.added`, `org_member.role_changed` |
|
||||
|
||||
---
|
||||
|
||||
<FAQ items={[
|
||||
{
|
||||
question: "Who can view audit logs?",
|
||||
answer: "Organization owners and admins can view audit logs. On Sim Cloud, you must be on the Enterprise plan."
|
||||
},
|
||||
{
|
||||
question: "Are audit logs tamper-proof?",
|
||||
answer: "Audit log entries are append-only and cannot be modified or deleted through the Sim interface or API. They represent a reliable record of actions taken in your organization."
|
||||
},
|
||||
{
|
||||
question: "Can I export audit logs?",
|
||||
answer: "Yes. Use the API to export logs programmatically. Paginate through all records using the cursor parameter and store them in your own data warehouse or SIEM."
|
||||
},
|
||||
{
|
||||
question: "Are logs scoped to a single workspace or the whole organization?",
|
||||
answer: "Audit logs are scoped to your organization and include activity across all workspaces within it. You can filter by workspaceId to narrow results to a specific workspace."
|
||||
},
|
||||
{
|
||||
question: "What information is included in each log entry?",
|
||||
answer: "Each entry includes the event type, a description, the actor's name and email, the affected resource, the workspace, and a timestamp. IP addresses and user agents are not exposed through the API."
|
||||
},
|
||||
{
|
||||
question: "Can I filter logs by a specific user?",
|
||||
answer: "Yes. Pass the actorId query parameter to filter logs by a specific user. The actor must be a current or former member of your organization."
|
||||
}
|
||||
]} />
|
||||
|
||||
---
|
||||
|
||||
## Self-hosted setup
|
||||
|
||||
Self-hosted deployments use environment variables instead of the billing/plan check.
|
||||
|
||||
### Environment variables
|
||||
|
||||
```bash
|
||||
AUDIT_LOGS_ENABLED=true
|
||||
NEXT_PUBLIC_AUDIT_LOGS_ENABLED=true
|
||||
```
|
||||
|
||||
Once enabled, audit logs are viewable in **Settings → Enterprise → Audit Logs** and accessible via the API.
|
||||
131
apps/docs/content/docs/en/enterprise/data-retention.mdx
Normal file
@@ -0,0 +1,131 @@
|
||||
---
|
||||
title: Data Retention
|
||||
description: Control how long execution logs, deleted resources, and copilot data are kept before permanent deletion
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout'
|
||||
import { FAQ } from '@/components/ui/faq'
|
||||
import { Image } from '@/components/ui/image'
|
||||
|
||||
Data Retention lets workspace admins on Enterprise plans configure how long three categories of data are kept before they are permanently deleted. Each workspace in your organization can have its own independent configuration.
|
||||
|
||||
---
|
||||
|
||||
## Setup
|
||||
|
||||
Go to **Settings → Enterprise → Data Retention** in your workspace.
|
||||
|
||||
<Image src="/static/enterprise/data-retention.png" alt="Data Retention settings showing three dropdowns — Log retention, Soft deletion cleanup, and Task cleanup — each set to Forever" width={900} height={500} />
|
||||
|
||||
You will see three independent settings, each with the same set of options: **1 day, 3 days, 7 days, 14 days, 30 days, 60 days, 90 days, 180 days, 1 year, 5 years,** or **Forever**.
|
||||
|
||||
Setting a period to **Forever** means that category of data is never automatically deleted.
|
||||
|
||||
---
|
||||
|
||||
## Settings
|
||||
|
||||
### Log retention
|
||||
|
||||
Controls how long **workflow execution logs** are kept.
|
||||
|
||||
When the retention period expires, execution log records are permanently deleted, along with any files associated with those executions stored in cloud storage.
|
||||
|
||||
### Soft deletion cleanup
|
||||
|
||||
Controls how long **soft-deleted resources** remain recoverable before permanent removal.
|
||||
|
||||
When you delete a workflow, folder, knowledge base, table, or file, it is initially soft-deleted and can be recovered from Recently Deleted. Once the soft deletion cleanup period expires, those resources are permanently removed and cannot be recovered.
|
||||
|
||||
Resources covered:
|
||||
|
||||
- Workflows
|
||||
- Workflow folders
|
||||
- Knowledge bases
|
||||
- Tables
|
||||
- Files
|
||||
- MCP server configurations
|
||||
- Agent memory
|
||||
|
||||
### Task cleanup
|
||||
|
||||
Controls how long **Mothership data** is kept, including:
|
||||
|
||||
- Copilot chats and run history
|
||||
- Run checkpoints and async tool calls
|
||||
- Inbox tasks (Sim Mailer)
|
||||
|
||||
<Callout type="info">
|
||||
Each setting is independent. You can configure a short log retention period alongside a long soft deletion cleanup period, or set any combination that fits your compliance requirements.
|
||||
</Callout>
|
||||
|
||||
---
|
||||
|
||||
## Per-workspace configuration
|
||||
|
||||
Retention is configured at the **workspace level**, not organization-wide. Each workspace in your organization can have a different configuration. Changes to one workspace's settings do not affect other workspaces.
|
||||
|
||||
---
|
||||
|
||||
## Plan defaults
|
||||
|
||||
Non-enterprise workspaces use the following automatic defaults. These cannot be changed.
|
||||
|
||||
| Setting | Free | Pro | Team |
|
||||
|---------|------|-----|------|
|
||||
| Log retention | 30 days | Not configured | Not configured |
|
||||
| Soft deletion cleanup | 30 days | 90 days | 90 days |
|
||||
| Task cleanup | Not configured | Not configured | Not configured |
|
||||
|
||||
"Not configured" means that category of data is not automatically deleted on that plan.
|
||||
|
||||
Enterprise workspaces have no defaults — retention only runs for a setting once you configure it. Until configured, that category of data is not automatically deleted.
|
||||
|
||||
<Callout type="info">
|
||||
On Enterprise, setting a period to **Forever** explicitly keeps data indefinitely. Leaving a setting unconfigured has the same effect, but setting it to Forever makes the intent explicit and allows you to change it later without needing to save from scratch.
|
||||
</Callout>
|
||||
|
||||
---
|
||||
|
||||
<FAQ items={[
|
||||
{
|
||||
question: "Who can configure data retention settings?",
|
||||
answer: "Only workspace admins can configure data retention settings. On Sim Cloud, the workspace must be on an Enterprise plan."
|
||||
},
|
||||
{
|
||||
question: "Is deletion immediate once the retention period expires?",
|
||||
answer: "No. Deletion runs on a scheduled cleanup job. Data is deleted when the job next runs after the retention period has elapsed — not at the exact moment it expires."
|
||||
},
|
||||
{
|
||||
question: "Can deleted data be recovered after the soft deletion cleanup period?",
|
||||
answer: "No. Once the soft deletion cleanup period expires and the cleanup job runs, resources are permanently deleted and cannot be recovered."
|
||||
},
|
||||
{
|
||||
question: "Does the retention period apply to all workspaces in my organization?",
|
||||
answer: "No. Retention is configured per workspace. Each workspace in your organization can have a different configuration."
|
||||
},
|
||||
{
|
||||
question: "What happens if I shorten the retention period?",
|
||||
answer: "The next cleanup job will delete any data that is older than the new, shorter period — including data that would have been kept under the previous setting. Shortening the period is irreversible for data that falls outside the new window."
|
||||
},
|
||||
{
|
||||
question: "What is the minimum retention period?",
|
||||
answer: "1 day (24 hours)."
|
||||
},
|
||||
{
|
||||
question: "What is the maximum retention period?",
|
||||
answer: "5 years."
|
||||
}
|
||||
]} />
|
||||
|
||||
---
|
||||
|
||||
## Self-hosted setup
|
||||
|
||||
### Environment variables
|
||||
|
||||
```bash
|
||||
NEXT_PUBLIC_DATA_RETENTION_ENABLED=true
|
||||
```
|
||||
|
||||
Once enabled, data retention settings are configurable through **Settings → Enterprise → Data Retention** the same way as Sim Cloud.
|
||||
@@ -12,7 +12,7 @@ Sim Enterprise provides advanced features for organizations with enhanced securi
|
||||
|
||||
## Access Control
|
||||
|
||||
Define permission groups to control what features and integrations team members can use.
|
||||
Define permission groups on a workspace to control what features and integrations its members can use. Permission groups are scoped to a single workspace — a user can belong to different groups (or no group) in different workspaces.
|
||||
|
||||
### Features
|
||||
|
||||
@@ -22,38 +22,21 @@ Define permission groups to control what features and integrations team members
|
||||
|
||||
### Setup
|
||||
|
||||
1. Navigate to **Settings** → **Access Control** in your workspace
|
||||
1. Navigate to **Settings** → **Access Control** in the workspace you want to manage
|
||||
2. Create a permission group with your desired restrictions
|
||||
3. Add team members to the permission group
|
||||
3. Add workspace members to the permission group
|
||||
|
||||
<Callout type="info">
|
||||
Users not assigned to any permission group have full access. Permission restrictions are enforced at both UI and execution time.
|
||||
Any workspace admin on an Enterprise-entitled workspace can manage permission groups. Users not assigned to any group have full access. Permission restrictions are enforced at both UI and execution time, and apply to workflows based on the workflow's workspace.
|
||||
</Callout>
|
||||
|
||||
---
|
||||
|
||||
## Single Sign-On (SSO)
|
||||
|
||||
Enterprise authentication with SAML 2.0 and OIDC support for centralized identity management.
|
||||
Enterprise authentication with SAML 2.0 and OIDC support. Works with Okta, Azure AD (Entra ID), Google Workspace, ADFS, and any standard OIDC or SAML 2.0 provider.
|
||||
|
||||
### Supported Providers
|
||||
|
||||
- Okta
|
||||
- Azure AD / Entra ID
|
||||
- Google Workspace
|
||||
- OneLogin
|
||||
- Any SAML 2.0 or OIDC provider
|
||||
|
||||
### Setup
|
||||
|
||||
1. Navigate to **Settings** → **SSO** in your workspace
|
||||
2. Choose your identity provider
|
||||
3. Configure the connection using your IdP's metadata
|
||||
4. Enable SSO for your organization
|
||||
|
||||
<Callout type="info">
|
||||
Once SSO is enabled, team members authenticate through your identity provider instead of email/password.
|
||||
</Callout>
|
||||
See the [SSO setup guide](/docs/enterprise/sso) for step-by-step instructions and provider-specific configuration.
|
||||
|
||||
---
|
||||
|
||||
@@ -110,7 +93,7 @@ curl -X DELETE "https://your-instance/api/v1/admin/workspaces/{workspaceId}/memb
|
||||
|
||||
### Notes
|
||||
|
||||
- Enabling `ACCESS_CONTROL_ENABLED` automatically enables organizations, as access control requires organization membership.
|
||||
- Access Control is scoped per workspace. Set `ACCESS_CONTROL_ENABLED` and `NEXT_PUBLIC_ACCESS_CONTROL_ENABLED` to enable it on every workspace in a self-hosted deployment, bypassing the Enterprise plan check.
|
||||
- When `DISABLE_INVITATIONS` is set, users cannot send invitations. Use the Admin API to manage workspace and organization memberships instead.
|
||||
|
||||
<FAQ items={[
|
||||
@@ -121,5 +104,5 @@ curl -X DELETE "https://your-instance/api/v1/admin/workspaces/{workspaceId}/memb
|
||||
{ question: "Which SSO providers are supported?", answer: "Sim supports SAML 2.0 and OIDC protocols, which means it works with virtually any enterprise identity provider including Okta, Azure AD (Entra ID), Google Workspace, and OneLogin. Configuration is done through Settings in the workspace UI." },
|
||||
{ question: "How do I manage users when invitations are disabled?", answer: "Use the Admin API with your admin API key. You can create organizations, add members to organizations with specific roles, add users to workspaces with defined permissions, and remove users. All management is done through REST API calls authenticated with the x-admin-key header." },
|
||||
{ question: "Can I scale Sim horizontally for high availability?", answer: "The Docker Compose setup is designed for single-node deployments. For production scaling, you can deploy on Kubernetes with multiple application replicas behind a load balancer. The database can be scaled independently using managed PostgreSQL services. Redis can be configured for session and cache management across multiple instances." },
|
||||
{ question: "How do access control permission groups work?", answer: "Permission groups let you restrict which AI providers, workflow blocks, and platform features are available to specific team members. Users not assigned to any group have full access. Restrictions are enforced at both the UI level (hiding restricted options) and at execution time (blocking unauthorized operations). Enabling access control automatically enables organization management." },
|
||||
{ question: "How do access control permission groups work?", answer: "Permission groups are created per workspace and let you restrict which AI providers, workflow blocks, and platform features are available to specific members of that workspace. Each user can belong to at most one group per workspace (and different groups in different workspaces). Users not assigned to any group have full access. Restrictions are enforced at both the UI level (hiding restricted options) and at execution time (blocking unauthorized operations) — execution enforcement is based on the workflow's workspace. Any workspace admin on an Enterprise-entitled workspace can manage permission groups." },
|
||||
]} />
|
||||
|
||||
5
apps/docs/content/docs/en/enterprise/meta.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"title": "Enterprise",
|
||||
"pages": ["index", "sso", "access-control", "whitelabeling", "audit-logs", "data-retention"],
|
||||
"defaultOpen": false
|
||||
}
|
||||
340
apps/docs/content/docs/en/enterprise/sso.mdx
Normal file
@@ -0,0 +1,340 @@
|
||||
---
|
||||
title: Single Sign-On (SSO)
|
||||
description: Configure SAML 2.0 or OIDC-based single sign-on for your organization
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout'
|
||||
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'
|
||||
import { FAQ } from '@/components/ui/faq'
|
||||
import { Image } from '@/components/ui/image'
|
||||
|
||||
Single Sign-On lets your team sign in to Sim through your company's identity provider instead of managing separate passwords. Sim supports both OIDC and SAML 2.0.
|
||||
|
||||
---
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Open SSO settings
|
||||
|
||||
Go to **Settings → Enterprise → Single Sign-On** in your workspace.
|
||||
|
||||
### 2. Choose a protocol
|
||||
|
||||
| Protocol | Use when |
|
||||
|----------|----------|
|
||||
| **OIDC** | Your IdP supports OpenID Connect — Okta, Microsoft Entra ID, Auth0, Google Workspace |
|
||||
| **SAML 2.0** | Your IdP is SAML-only — ADFS, Shibboleth, or older enterprise IdPs |
|
||||
|
||||
### 3. Fill in the form
|
||||
|
||||
<Image src="/static/enterprise/sso-form.png" alt="Single Sign-On configuration form showing Provider Type (OIDC), Provider ID, Issuer URL, Domain, Client ID, Client Secret, Scopes, and Callback URL fields" width={900} height={500} />
|
||||
|
||||
**Fields required for both protocols:**
|
||||
|
||||
| Field | What to enter |
|
||||
|-------|--------------|
|
||||
| **Provider ID** | A short slug identifying this connection, e.g. `okta` or `azure-ad`. Letters, numbers, and dashes only. |
|
||||
| **Issuer URL** | The identity provider's issuer URL. Must be HTTPS. |
|
||||
| **Domain** | Your organization's email domain, e.g. `company.com`. Users with this domain will be routed through SSO at sign-in. |
|
||||
|
||||
**OIDC additional fields:**
|
||||
|
||||
| Field | What to enter |
|
||||
|-------|--------------|
|
||||
| **Client ID** | The application client ID from your IdP. |
|
||||
| **Client Secret** | The client secret from your IdP. |
|
||||
| **Scopes** | Comma-separated OIDC scopes. Default: `openid,profile,email`. |
|
||||
|
||||
<Callout type="info">
|
||||
For OIDC, Sim automatically fetches endpoints (`authorization_endpoint`, `token_endpoint`, `userinfo_endpoint`, `jwks_uri`) from your issuer's `/.well-known/openid-configuration` discovery document. You only need to provide the issuer URL.
|
||||
</Callout>
|
||||
|
||||
**SAML additional fields:**
|
||||
|
||||
| Field | What to enter |
|
||||
|-------|--------------|
|
||||
| **Entry Point URL** | The IdP's SSO service URL where Sim sends authentication requests. |
|
||||
| **Identity Provider Certificate** | The Base-64 encoded X.509 certificate from your IdP for verifying assertions. |
|
||||
|
||||
### 4. Copy the Callback URL
|
||||
|
||||
The **Callback URL** shown in the form is the endpoint your identity provider must redirect users back to after authentication. Copy it and register it in your IdP before saving.
|
||||
|
||||
**OIDC providers** (Okta, Microsoft Entra ID, Google Workspace, Auth0):
|
||||
```
|
||||
https://simstudio.ai/api/auth/sso/callback/{provider-id}
|
||||
```
|
||||
|
||||
**SAML providers** (ADFS, Shibboleth):
|
||||
```
|
||||
https://simstudio.ai/api/auth/sso/saml2/callback/{provider-id}
|
||||
```
|
||||
|
||||
For self-hosted, replace `simstudio.ai` with your instance hostname.
|
||||
|
||||
### 5. Save and test
|
||||
|
||||
Click **Save**. To test, sign out and use the **Sign in with SSO** button on the login page. Enter an email address at your configured domain — Sim will redirect you to your identity provider.
|
||||
|
||||
---
|
||||
|
||||
## Provider Guides
|
||||
|
||||
<Tabs items={['Okta', 'Microsoft Entra ID', 'Google Workspace', 'ADFS']}>
|
||||
|
||||
<Tab value="Okta">
|
||||
|
||||
### Okta (OIDC)
|
||||
|
||||
**In Okta** ([official docs](https://help.okta.com/en-us/content/topics/apps/apps_app_integration_wizard_oidc.htm)):
|
||||
|
||||
1. Go to **Applications → Create App Integration**
|
||||
2. Select **OIDC - OpenID Connect**, then **Web Application**
|
||||
3. Set the **Sign-in redirect URI** to your Sim callback URL:
|
||||
```
|
||||
https://simstudio.ai/api/auth/sso/callback/okta
|
||||
```
|
||||
4. Under **Assignments**, grant access to the relevant users or groups
|
||||
5. Copy the **Client ID** and **Client Secret** from the app's **General** tab
|
||||
6. Your Okta domain is the hostname of your admin console, e.g. `dev-1234567.okta.com`
|
||||
|
||||
**In Sim:**
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Provider Type | OIDC |
|
||||
| Provider ID | `okta` |
|
||||
| Issuer URL | `https://dev-1234567.okta.com/oauth2/default` |
|
||||
| Domain | `company.com` |
|
||||
| Client ID | From Okta app |
|
||||
| Client Secret | From Okta app |
|
||||
|
||||
<Callout type="info">
|
||||
The issuer URL uses Okta's default authorization server (`/oauth2/default`), which is pre-configured on every Okta org. If you created a custom authorization server, replace `default` with your server name.
|
||||
</Callout>
|
||||
|
||||
</Tab>
|
||||
|
||||
<Tab value="Microsoft Entra ID">
|
||||
|
||||
### Microsoft Entra ID (OIDC)
|
||||
|
||||
**In Azure** ([official docs](https://learn.microsoft.com/en-us/entra/identity-platform/quickstart-register-app)):
|
||||
|
||||
1. Go to **Microsoft Entra ID → App registrations → New registration**
|
||||
2. Under **Redirect URI**, select **Web** and enter your Sim callback URL:
|
||||
```
|
||||
https://simstudio.ai/api/auth/sso/callback/azure-ad
|
||||
```
|
||||
3. After registration, go to **Certificates & secrets → New client secret** and copy the value immediately — it won't be shown again
|
||||
4. Go to **Overview** and copy the **Application (client) ID** and **Directory (tenant) ID**
|
||||
|
||||
**In Sim:**
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Provider Type | OIDC |
|
||||
| Provider ID | `azure-ad` |
|
||||
| Issuer URL | `https://login.microsoftonline.com/{tenant-id}/v2.0` |
|
||||
| Domain | `company.com` |
|
||||
| Client ID | Application (client) ID |
|
||||
| Client Secret | Secret value |
|
||||
|
||||
<Callout type="info">
|
||||
Replace `{tenant-id}` with your Directory (tenant) ID from the app's Overview page. Sim auto-discovers token and JWKS endpoints from the issuer.
|
||||
</Callout>
|
||||
|
||||
</Tab>
|
||||
|
||||
<Tab value="Google Workspace">
|
||||
|
||||
### Google Workspace (OIDC)
|
||||
|
||||
**In Google Cloud Console** ([official docs](https://developers.google.com/identity/openid-connect/openid-connect)):
|
||||
|
||||
1. Go to **APIs & Services → Credentials → Create Credentials → OAuth 2.0 Client ID**
|
||||
2. Set the application type to **Web application**
|
||||
3. Add your Sim callback URL to **Authorized redirect URIs**:
|
||||
```
|
||||
https://simstudio.ai/api/auth/sso/callback/google-workspace
|
||||
```
|
||||
4. Copy the **Client ID** and **Client Secret**
|
||||
|
||||
**In Sim:**
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Provider Type | OIDC |
|
||||
| Provider ID | `google-workspace` |
|
||||
| Issuer URL | `https://accounts.google.com` |
|
||||
| Domain | `company.com` |
|
||||
| Client ID | From Google Cloud Console |
|
||||
| Client Secret | From Google Cloud Console |
|
||||
|
||||
<Callout type="info">
|
||||
To restrict sign-in to your Google Workspace domain, configure the OAuth consent screen and ensure your app is set to **Internal** (Workspace users only) under **User type**. Setting the app to Internal limits access to users within your Google Workspace organization.
|
||||
</Callout>
|
||||
|
||||
</Tab>
|
||||
|
||||
<Tab value="ADFS">
|
||||
|
||||
### ADFS (SAML 2.0)
|
||||
|
||||
**In ADFS** ([official docs](https://learn.microsoft.com/en-us/windows-server/identity/ad-fs/operations/create-a-relying-party-trust)):
|
||||
|
||||
1. Open **AD FS Management → Relying Party Trusts → Add Relying Party Trust**
|
||||
2. Choose **Claims aware**, then **Enter data about the relying party manually**
|
||||
3. Set the **Relying party identifier** (Entity ID) to your Sim base URL:
|
||||
```
|
||||
https://simstudio.ai
|
||||
```
|
||||
For self-hosted, use your instance's base URL (e.g. `https://sim.company.com`)
|
||||
4. Add an endpoint: **SAML Assertion Consumer Service** (HTTP POST) with the URL:
|
||||
```
|
||||
https://simstudio.ai/api/auth/sso/saml2/callback/adfs
|
||||
```
|
||||
For self-hosted: `https://sim.company.com/api/auth/sso/saml2/callback/adfs`
|
||||
5. Export the **Token-signing certificate** from **Certificates**: right-click → **View Certificate → Details → Copy to File**, choose **Base-64 encoded X.509 (.CER)**. The `.cer` file is PEM-encoded — rename it to `.pem` before pasting its contents into Sim.
|
||||
6. Note the **ADFS Federation Service endpoint URL** (e.g. `https://adfs.company.com/adfs/ls`)
|
||||
|
||||
**In Sim:**
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Provider Type | SAML |
|
||||
| Provider ID | `adfs` |
|
||||
| Issuer URL | `https://simstudio.ai` |
|
||||
| Domain | `company.com` |
|
||||
| Entry Point URL | `https://adfs.company.com/adfs/ls` |
|
||||
| Certificate | Contents of the `.pem` file |
|
||||
|
||||
<Callout type="info">
|
||||
For ADFS, the **Issuer URL** field is the SP entity ID — the identifier ADFS uses to identify Sim as a relying party. It must match the **Relying party identifier** you registered in ADFS.
|
||||
</Callout>
|
||||
|
||||
</Tab>
|
||||
|
||||
</Tabs>
|
||||
|
||||
---
|
||||
|
||||
## How sign-in works after setup
|
||||
|
||||
Once SSO is configured, users with your domain (`company.com`) can sign in through your identity provider:
|
||||
|
||||
1. User goes to `simstudio.ai` and clicks **Sign in with SSO**
|
||||
2. They enter their work email (e.g. `alice@company.com`)
|
||||
3. Sim redirects them to your identity provider
|
||||
4. After authenticating, they are returned to Sim and added to your organization automatically
|
||||
5. They land in the workspace
|
||||
|
||||
Users who sign in via SSO for the first time are automatically provisioned and added to your organization — no manual invite required.
|
||||
|
||||
<Callout type="info">
|
||||
Password-based login remains available. Forcing all organization members to use SSO exclusively is not yet supported.
|
||||
</Callout>
|
||||
|
||||
<Callout type="info">
|
||||
**Self-hosted:** Automatic organization provisioning requires `ORGANIZATIONS_ENABLED=true` in addition to `SSO_ENABLED=true`. Without it, SSO authentication still works — users get a valid session — but they are not automatically added to an organization.
|
||||
</Callout>
|
||||
|
||||
---
|
||||
|
||||
<FAQ items={[
|
||||
{
|
||||
question: "Which SSO providers are supported?",
|
||||
answer: "Any identity provider that supports OIDC or SAML 2.0. This includes Okta, Microsoft Entra ID (Azure AD), Google Workspace, Auth0, OneLogin, JumpCloud, Ping Identity, ADFS, Shibboleth, and more."
|
||||
},
|
||||
{
|
||||
question: "What is the Domain field used for?",
|
||||
answer: "The domain (e.g. company.com) is how Sim routes users to the right identity provider. When a user enters their email on the SSO sign-in page, Sim matches their email domain to a registered SSO provider and redirects them there."
|
||||
},
|
||||
{
|
||||
question: "Do I need to provide OIDC endpoints manually?",
|
||||
answer: "No. For OIDC providers, Sim automatically fetches the authorization, token, and JWKS endpoints from the discovery document at {issuer}/.well-known/openid-configuration. You only need to provide the issuer URL."
|
||||
},
|
||||
{
|
||||
question: "What happens when a user signs in with SSO for the first time?",
|
||||
answer: "Sim creates an account for them automatically and adds them to your organization. No manual invite is needed. They are assigned the member role by default."
|
||||
},
|
||||
{
|
||||
question: "Can I still use email/password login after enabling SSO?",
|
||||
answer: "Yes. Enabling SSO does not disable password-based login. Users can still sign in with their email and password if they have one. Forced SSO (requiring all users on the domain to use SSO) is not yet supported."
|
||||
},
|
||||
{
|
||||
question: "Who can configure SSO on Sim Cloud?",
|
||||
answer: "Organization owners and admins can configure SSO. You must be on the Enterprise plan."
|
||||
},
|
||||
{
|
||||
question: "What is the Callback URL?",
|
||||
answer: "The Callback URL (also called Redirect URI or ACS URL) is the endpoint in Sim that receives the authentication response from your identity provider. For OIDC providers it follows the format: https://simstudio.ai/api/auth/sso/callback/{provider-id}. For SAML providers it is: https://simstudio.ai/api/auth/sso/saml2/callback/{provider-id}. You must register this URL in your identity provider before SSO will work."
|
||||
},
|
||||
{
|
||||
question: "How do I update or replace an existing SSO configuration?",
|
||||
answer: "Open Settings → Enterprise → Single Sign-On and click Edit. Update the fields and save. The existing provider configuration is replaced."
|
||||
}
|
||||
]} />
|
||||
|
||||
---
|
||||
|
||||
## Self-hosted setup
|
||||
|
||||
Self-hosted deployments use environment variables instead of the billing/plan check.
|
||||
|
||||
### Environment variables
|
||||
|
||||
```bash
|
||||
# Required
|
||||
SSO_ENABLED=true
|
||||
NEXT_PUBLIC_SSO_ENABLED=true
|
||||
|
||||
# Required if you want users auto-added to your organization on first SSO sign-in
|
||||
ORGANIZATIONS_ENABLED=true
|
||||
NEXT_PUBLIC_ORGANIZATIONS_ENABLED=true
|
||||
```
|
||||
|
||||
You can register providers through the **Settings UI** (same as cloud) or by running the registration script directly against your database.
|
||||
|
||||
### Script-based registration
|
||||
|
||||
Use this when you need to register an SSO provider without going through the UI — for example, during initial deployment or CI/CD automation.
|
||||
|
||||
```bash
|
||||
# OIDC example (Okta)
|
||||
SSO_ENABLED=true \
|
||||
NEXT_PUBLIC_APP_URL=https://your-instance.com \
|
||||
SSO_PROVIDER_TYPE=oidc \
|
||||
SSO_PROVIDER_ID=okta \
|
||||
SSO_ISSUER=https://dev-1234567.okta.com/oauth2/default \
|
||||
SSO_DOMAIN=company.com \
|
||||
SSO_USER_EMAIL=admin@company.com \
|
||||
SSO_OIDC_CLIENT_ID=your-client-id \
|
||||
SSO_OIDC_CLIENT_SECRET=your-client-secret \
|
||||
bun run packages/db/scripts/register-sso-provider.ts
|
||||
```
|
||||
|
||||
```bash
|
||||
# SAML example (ADFS)
|
||||
SSO_ENABLED=true \
|
||||
NEXT_PUBLIC_APP_URL=https://your-instance.com \
|
||||
SSO_PROVIDER_TYPE=saml \
|
||||
SSO_PROVIDER_ID=adfs \
|
||||
SSO_ISSUER=https://your-instance.com \
|
||||
SSO_DOMAIN=company.com \
|
||||
SSO_USER_EMAIL=admin@company.com \
|
||||
SSO_SAML_ENTRY_POINT=https://adfs.company.com/adfs/ls \
|
||||
SSO_SAML_CERT="-----BEGIN CERTIFICATE-----
|
||||
...
|
||||
-----END CERTIFICATE-----" \
|
||||
bun run packages/db/scripts/register-sso-provider.ts
|
||||
```
|
||||
|
||||
The script outputs the callback URL to configure in your IdP once it completes.
|
||||
|
||||
To remove a provider:
|
||||
|
||||
```bash
|
||||
SSO_USER_EMAIL=admin@company.com \
|
||||
bun run packages/db/scripts/deregister-sso-provider.ts
|
||||
```
|
||||
106
apps/docs/content/docs/en/enterprise/whitelabeling.mdx
Normal file
@@ -0,0 +1,106 @@
|
||||
---
|
||||
title: Whitelabeling
|
||||
description: Replace Sim branding with your own logo, colors, and links
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout'
|
||||
import { FAQ } from '@/components/ui/faq'
|
||||
import { Image } from '@/components/ui/image'
|
||||
|
||||
Whitelabeling lets you replace Sim's default branding — logo, colors, and support links — with your own. Members of your organization see your brand instead of Sim's throughout the workspace.
|
||||
|
||||
---
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Open Whitelabeling settings
|
||||
|
||||
Go to **Settings → Enterprise → Whitelabeling** in your workspace.
|
||||
|
||||
<Image src="/static/enterprise/whitelabeling.png" alt="Whitelabeling settings showing brand identity fields (Logo, Wordmark, Brand name), color pickers for primary and accent colors, and link fields for support email and documentation URL" width={900} height={500} />
|
||||
|
||||
### 2. Configure brand identity
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| **Logo** | Shown in the collapsed sidebar. Square image (PNG, JPEG, SVG, or WebP). Max 5 MB. |
|
||||
| **Wordmark** | Shown in the expanded sidebar. Wide image (PNG, JPEG, SVG, or WebP). Max 5 MB. |
|
||||
| **Brand name** | Replaces "Sim" in the sidebar and select UI elements. Max 64 characters. |
|
||||
|
||||
|
||||
### 3. Configure colors
|
||||
|
||||
All colors must be valid hex values (e.g. `#701ffc`).
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| **Primary color** | Main accent color used for buttons and active states. |
|
||||
| **Primary hover color** | Color shown when hovering over primary elements. |
|
||||
| **Accent color** | Secondary accent for highlights and secondary interactive elements. |
|
||||
| **Accent hover color** | Color shown when hovering over accent elements. |
|
||||
|
||||
### 4. Configure links
|
||||
|
||||
Replace Sim's default support and legal links with your own.
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| **Support email** | Shown in help prompts. Must be a valid email address. |
|
||||
| **Documentation URL** | Link to your internal documentation. Must be a valid URL. |
|
||||
| **Terms of service URL** | Link to your terms page. Must be a valid URL. |
|
||||
| **Privacy policy URL** | Link to your privacy page. Must be a valid URL. |
|
||||
|
||||
### 5. Save
|
||||
|
||||
Click **Save changes**. The new branding is applied immediately for all members of your organization.
|
||||
|
||||
---
|
||||
|
||||
## What gets replaced
|
||||
|
||||
Whitelabeling replaces the following visual elements:
|
||||
|
||||
- **Sidebar logo and wordmark** — your uploaded images replace the Sim logo
|
||||
- **Brand name** — appears in the sidebar and select UI labels
|
||||
- **Primary and accent colors** — applied to buttons, active states, and highlights
|
||||
- **Support and legal links** — help prompts and footer links point to your URLs
|
||||
|
||||
<Callout type="info">
|
||||
Whitelabeling applies only to members of your organization. Public-facing pages (login, marketing) are not affected.
|
||||
</Callout>
|
||||
|
||||
---
|
||||
|
||||
<FAQ items={[
|
||||
{
|
||||
question: "Who can configure whitelabeling?",
|
||||
answer: "Organization owners and admins can configure whitelabeling. On Sim Cloud, you must be on the Enterprise plan."
|
||||
},
|
||||
{
|
||||
question: "What image formats are supported?",
|
||||
answer: "PNG, JPEG, SVG, and WebP. Maximum file size is 5 MB for both the logo and wordmark."
|
||||
},
|
||||
{
|
||||
question: "What is the difference between the logo and the wordmark?",
|
||||
answer: "The logo is a square image shown in the collapsed sidebar. The wordmark is a wide image shown in the expanded sidebar alongside member names and navigation items."
|
||||
},
|
||||
{
|
||||
question: "Do members outside my organization see the custom branding?",
|
||||
answer: "No. Custom branding is scoped to your organization. Members see your branding when signed in to your organization's workspace."
|
||||
}
|
||||
]} />
|
||||
|
||||
---
|
||||
|
||||
## Self-hosted setup
|
||||
|
||||
Self-hosted deployments use environment variables instead of the billing/plan check.
|
||||
|
||||
### Environment variables
|
||||
|
||||
```bash
|
||||
WHITELABELING_ENABLED=true
|
||||
NEXT_PUBLIC_WHITELABELING_ENABLED=true
|
||||
```
|
||||
|
||||
Once enabled, configure branding through **Settings → Enterprise → Whitelabeling** the same way as Sim Cloud.
|
||||
@@ -308,6 +308,17 @@ By default, your usage is capped at the credits included in your plan. To allow
|
||||
|
||||
## Plan Limits
|
||||
|
||||
### Workspaces
|
||||
|
||||
| Plan | Personal Workspaces | Shared (Organization) Workspaces |
|
||||
|------|---------------------|----------------------------------|
|
||||
| **Free** | 1 | — |
|
||||
| **Pro** | Up to 3 | — |
|
||||
| **Max** | Up to 10 | — |
|
||||
| **Team / Enterprise** | Unlimited | Unlimited |
|
||||
|
||||
Team and Enterprise plans unlock shared workspaces that belong to your organization. Members invited to a shared workspace automatically join the organization and count toward your seat total. When a Team or Enterprise subscription is cancelled or downgraded, existing shared workspaces remain accessible to current members but new invites are disabled until the organization is upgraded again.
|
||||
|
||||
### Rate Limits
|
||||
|
||||
| Plan | Sync (req/min) | Async (req/min) |
|
||||
|
||||
@@ -220,6 +220,6 @@ import { FAQ } from '@/components/ui/faq'
|
||||
{ question: "Who can configure MCP servers in a workspace?", answer: "Users with Write permission can configure (add and update) MCP servers in workspace settings. Only Admin permission is required to delete MCP servers. Users with Read permission can view available MCP tools and execute them in agents and MCP Tool blocks. This means all workspace members with at least Read access can use MCP tools in their workflows." },
|
||||
{ question: "Can I use MCP servers from multiple workspaces?", answer: "MCP servers are configured per workspace. Each workspace maintains its own set of MCP server connections. If you need the same MCP server in multiple workspaces, you need to configure it separately in each workspace's settings." },
|
||||
{ question: "How do I update MCP tool schemas after a server changes its available tools?", answer: "Click the Refresh button on the MCP server in your workspace settings. This fetches the latest tool schemas from the server and automatically updates any agent blocks that use those tools with the new parameter definitions." },
|
||||
{ question: "Can permission groups restrict access to MCP tools?", answer: "Yes. Organization admins can create permission groups that disable MCP tools for specific members using the disableMcpTools configuration option. When this is enabled, affected users will not be able to add or use MCP tools in their workflows." },
|
||||
{ question: "Can permission groups restrict access to MCP tools?", answer: "Yes. On Enterprise-entitled workspaces, any workspace admin can create a permission group that disables MCP tools for its members using the disableMcpTools option. When this is enabled, affected users will not be able to add or use MCP tools in workflows that belong to that workspace." },
|
||||
{ question: "What happens if an MCP server goes offline during workflow execution?", answer: "If the MCP server is unreachable during execution, the tool call will fail and return an error. In an Agent block, the AI may attempt to handle the failure gracefully. In a standalone MCP Tool block, the workflow step will fail. Check MCP server logs and verify the server is running and accessible to troubleshoot connectivity issues." },
|
||||
]} />
|
||||
@@ -25,7 +25,7 @@
|
||||
"execution",
|
||||
"permissions",
|
||||
"self-hosting",
|
||||
"./enterprise/index",
|
||||
"enterprise",
|
||||
"./keyboard-shortcuts/index"
|
||||
],
|
||||
"defaultOpen": false
|
||||
|
||||
@@ -2,10 +2,31 @@
|
||||
title: "Roles and Permissions"
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout'
|
||||
import { Video } from '@/components/ui/video'
|
||||
|
||||
When you invite team members to your organization or workspace, you'll need to choose what level of access to give them. This guide explains what each permission level allows users to do, helping you understand team roles and what access each permission level provides.
|
||||
|
||||
## Workspaces and Organizations
|
||||
|
||||
Sim has two kinds of workspaces:
|
||||
|
||||
- **Personal workspaces** live under your individual account. The number you can create depends on your plan.
|
||||
- **Shared (organization) workspaces** live under an organization and are available on Team and Enterprise plans. Any organization Owner or Admin can create them. Members invited to a shared workspace automatically join the organization and count toward your seat total.
|
||||
|
||||
### Workspace Limits by Plan
|
||||
|
||||
| Plan | Personal Workspaces | Shared Workspaces |
|
||||
|------|---------------------|-------------------|
|
||||
| **Free** | 1 | — |
|
||||
| **Pro** | Up to 3 | — |
|
||||
| **Max** | Up to 10 | — |
|
||||
| **Team / Enterprise** | Unlimited | Unlimited (seat-gated invites) |
|
||||
|
||||
<Callout type="info">
|
||||
When a Team or Enterprise subscription is cancelled or downgraded, existing shared workspaces stay accessible to current members. New invitations are blocked until the organization is upgraded again.
|
||||
</Callout>
|
||||
|
||||
## How to Invite Someone to a Workspace
|
||||
|
||||
<div className="mx-auto w-full overflow-hidden rounded-lg">
|
||||
@@ -88,6 +109,10 @@ Every workspace has one **Owner** (the person who created it) plus any number of
|
||||
- Can do everything except delete the workspace or remove the owner
|
||||
- Can be removed from the workspace by the owner or other admins
|
||||
|
||||
<Callout type="info">
|
||||
For shared (organization) workspaces, the organization's Owner and Admins are treated as Admins of every workspace in the organization, even without an explicit per-workspace invite.
|
||||
</Callout>
|
||||
|
||||
---
|
||||
|
||||
## Common Scenarios
|
||||
@@ -145,28 +170,41 @@ Periodically review who has access to what, especially when team members change
|
||||
|
||||
## Organization Roles
|
||||
|
||||
When inviting someone to your organization, you can assign one of two roles:
|
||||
An organization has three roles: **Owner**, **Admin**, and **Member**.
|
||||
|
||||
### Organization Owner
|
||||
**What they can do:**
|
||||
- Everything an Admin can do
|
||||
- Transfer organization ownership to another user
|
||||
- Only one Owner exists per organization
|
||||
|
||||
### Organization Admin
|
||||
**What they can do:**
|
||||
- Invite and remove team members from the organization
|
||||
- Create new workspaces
|
||||
- Manage billing and subscription settings
|
||||
- Access all workspaces within the organization
|
||||
- Create new shared workspaces under the organization
|
||||
- Manage billing, seat count, and subscription settings
|
||||
- Access all shared workspaces within the organization as a workspace Admin
|
||||
- Promote members to Admin or demote Admins to Member
|
||||
|
||||
<Callout type="info">
|
||||
Owners and Admins have the same day-to-day permissions. The only action reserved for the Owner is transferring ownership.
|
||||
</Callout>
|
||||
|
||||
### Organization Member
|
||||
**What they can do:**
|
||||
- Access workspaces they've been specifically invited to
|
||||
- Access shared workspaces they've been specifically invited to
|
||||
- View the list of organization members
|
||||
- Cannot invite new people or manage organization settings
|
||||
- Cannot invite new people, create shared workspaces, or manage organization settings
|
||||
|
||||
import { FAQ } from '@/components/ui/faq'
|
||||
|
||||
<FAQ items={[
|
||||
{ question: "What is the difference between organization roles and workspace permissions?", answer: "Organization roles (Admin or Member) control who can manage the organization itself, including inviting people, creating workspaces, and handling billing. Workspace permissions (Read, Write, Admin) control what a user can do within a specific workspace, such as viewing, editing, or managing workflows. A user needs both an organization role and a workspace permission to work within a workspace." },
|
||||
{ question: "Can I restrict which integrations or model providers a team member can use?", answer: "Yes. Organization admins can create permission groups with fine-grained controls, including restricting allowed integrations and allowed model providers to specific lists. You can also disable access to MCP tools, custom tools, skills, and various platform features like the knowledge base, API keys, or Copilot on a per-group basis." },
|
||||
{ question: "What is the difference between organization roles and workspace permissions?", answer: "Organization roles (Owner, Admin, or Member) control who can manage the organization itself, including inviting people, creating shared workspaces, and handling billing. Workspace permissions (Read, Write, Admin) control what a user can do within a specific workspace, such as viewing, editing, or managing workflows. A user needs both an organization role and a workspace permission to work within a shared workspace." },
|
||||
{ question: "How many workspaces can I create?", answer: "Free users get 1 personal workspace. Pro users get up to 3 personal workspaces. Max users get up to 10 personal workspaces. Team and Enterprise plans support unlimited shared workspaces under the organization — new invites are gated by your seat count." },
|
||||
{ question: "What happens to my shared workspaces if I cancel or downgrade my Team plan?", answer: "Existing shared workspaces remain accessible to current members, but new invitations are disabled until you upgrade back to a Team or Enterprise plan. No workspaces or members are deleted — the organization is simply dormant until billing is re-enabled." },
|
||||
{ question: "Can I restrict which integrations or model providers a team member can use?", answer: "Yes, on Enterprise-entitled workspaces. Any workspace admin can create permission groups with fine-grained controls, including restricting allowed integrations and allowed model providers to specific lists. You can also disable access to MCP tools, custom tools, skills, and various platform features like the knowledge base, API keys, or Copilot on a per-group basis. Permission groups are scoped per workspace — a user can belong to different groups in different workspaces." },
|
||||
{ question: "What happens when a personal environment variable has the same name as a workspace variable?", answer: "The personal environment variable takes priority. When a workflow runs, if both a personal and workspace variable share the same name, the personal value is used. This allows individual users to override shared workspace configuration when needed." },
|
||||
{ question: "Can an Admin remove the workspace owner?", answer: "No. The workspace owner cannot be removed from the workspace by anyone. Only the workspace owner can delete the workspace or transfer ownership to another user. Admins can do everything else, including inviting and removing other users and managing workspace settings." },
|
||||
{ question: "What are permission groups and how do they work?", answer: "Permission groups are an advanced access control feature that lets organization admins define granular restrictions beyond the standard Read/Write/Admin roles. A permission group can hide UI sections (like trace spans, knowledge base, API keys, or deployment options), disable features (MCP tools, custom tools, skills, invitations), and restrict which integrations and model providers members can access. Members can be assigned to groups, and new members can be auto-added." },
|
||||
{ question: "What are permission groups and how do they work?", answer: "Permission groups are an Enterprise access control feature that lets workspace admins define granular restrictions beyond the standard Read/Write/Admin roles. Groups are scoped to a single workspace: each user can be in at most one group per workspace, and a user can be in different groups across different workspaces. A permission group can hide UI sections (like trace spans, knowledge base, API keys, or deployment options), disable features (MCP tools, custom tools, skills, invitations), and restrict which integrations and model providers its members can access. Members can be assigned manually, and new members can be auto-added on join. Execution-time enforcement is based on the workflow's workspace, not the user's current UI context." },
|
||||
{ question: "How should I set up permissions for a new team member?", answer: "Start with the lowest permission level they need. Invite them to the organization as a Member, then add them to the relevant workspace with Read permission if they only need visibility, Write if they need to create and run workflows, or Admin if they need to manage the workspace and its users. You can always increase permissions later." },
|
||||
]} />
|
||||
@@ -140,7 +140,7 @@ import { FAQ } from '@/components/ui/faq'
|
||||
{ question: "How does the agent decide when to load a skill?", answer: "The agent sees an available_skills section in its system prompt listing each skill's name and description. When the agent determines that a skill is relevant to the current task, it calls the load_skill tool with the skill name. The full skill content is then returned as a tool response. This is why writing a specific, keyword-rich description is critical -- it is the only thing the agent reads before deciding whether to activate a skill." },
|
||||
{ question: "Do skills work with all LLM providers?", answer: "Yes. The load_skill mechanism uses standard tool-calling, which is supported by all LLM providers in Sim. No provider-specific configuration is needed. The skill system works the same way whether you are using Anthropic, OpenAI, Google, or any other supported provider." },
|
||||
{ question: "When should I use skills vs. agent instructions?", answer: "Use skills for knowledge that applies across multiple workflows or changes frequently. Skills are reusable packages that can be attached to any agent. Use agent instructions for task-specific context that is unique to a single agent and workflow. If you find yourself copying the same instructions into multiple agents, that content should be a skill instead." },
|
||||
{ question: "Can permission groups disable skills for certain users?", answer: "Yes. Organization admins can create permission groups with the disableSkills option enabled. When a user is assigned to such a permission group, the skills dropdown in agent blocks will be disabled and they will not be able to add or use skills in their workflows." },
|
||||
{ question: "Can permission groups disable skills for certain users?", answer: "Yes. On Enterprise-entitled workspaces, any workspace admin can create a permission group with the disableSkills option enabled. When a user is assigned to such a group in a workspace, the skills dropdown in agent blocks is disabled and they cannot add or use skills in workflows belonging to that workspace." },
|
||||
{ question: "What is the recommended maximum length for skill content?", answer: "Keep skills focused and under 500 lines. If a skill grows too large, split it into multiple specialized skills. Shorter, focused skills are more effective because the agent can load exactly what it needs. A broad skill with too much content can overwhelm the agent and reduce the quality of its responses." },
|
||||
{ question: "Where do I create and manage skills?", answer: "Go to Settings and select Skills under the Tools section. From there you can add new skills with a name (kebab-case identifier, max 64 characters), description (max 1024 characters), and content (full instructions in markdown). You can also edit or delete existing skills from this page." },
|
||||
]} />
|
||||
|
||||
@@ -57,9 +57,12 @@ Run a CloudWatch Log Insights query against one or more log groups
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `results` | array | Query result rows |
|
||||
| `statistics` | object | Query statistics \(bytesScanned, recordsMatched, recordsScanned\) |
|
||||
| `status` | string | Query completion status |
|
||||
| `results` | array | Query result rows \(each row is a key/value map of field name to value\) |
|
||||
| `statistics` | object | Query statistics |
|
||||
| ↳ `bytesScanned` | number | Total bytes of log data scanned |
|
||||
| ↳ `recordsMatched` | number | Number of log records that matched the query |
|
||||
| ↳ `recordsScanned` | number | Total log records scanned |
|
||||
| `status` | string | Query completion status \(Complete, Failed, Cancelled, or Timeout\) |
|
||||
|
||||
### `cloudwatch_describe_log_groups`
|
||||
|
||||
@@ -80,6 +83,11 @@ List available CloudWatch log groups
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `logGroups` | array | List of CloudWatch log groups with metadata |
|
||||
| ↳ `logGroupName` | string | Log group name |
|
||||
| ↳ `arn` | string | Log group ARN |
|
||||
| ↳ `storedBytes` | number | Total stored bytes |
|
||||
| ↳ `retentionInDays` | number | Retention period in days \(if set\) |
|
||||
| ↳ `creationTime` | number | Creation time in epoch milliseconds |
|
||||
|
||||
### `cloudwatch_get_log_events`
|
||||
|
||||
@@ -103,6 +111,9 @@ Retrieve log events from a specific CloudWatch log stream
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `events` | array | Log events with timestamp, message, and ingestion time |
|
||||
| ↳ `timestamp` | number | Event timestamp in epoch milliseconds |
|
||||
| ↳ `message` | string | Log event message |
|
||||
| ↳ `ingestionTime` | number | Ingestion time in epoch milliseconds |
|
||||
|
||||
### `cloudwatch_describe_log_streams`
|
||||
|
||||
@@ -123,7 +134,12 @@ List log streams within a CloudWatch log group
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `logStreams` | array | List of log streams with metadata |
|
||||
| `logStreams` | array | List of log streams with metadata, sorted by last event time \(most recent first\) unless a prefix filter is applied |
|
||||
| ↳ `logStreamName` | string | Log stream name |
|
||||
| ↳ `lastEventTimestamp` | number | Timestamp of the last log event in epoch milliseconds |
|
||||
| ↳ `firstEventTimestamp` | number | Timestamp of the first log event in epoch milliseconds |
|
||||
| ↳ `creationTime` | number | Stream creation time in epoch milliseconds |
|
||||
| ↳ `storedBytes` | number | Total stored bytes |
|
||||
|
||||
### `cloudwatch_list_metrics`
|
||||
|
||||
@@ -146,6 +162,9 @@ List available CloudWatch metrics
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `metrics` | array | List of metrics with namespace, name, and dimensions |
|
||||
| ↳ `namespace` | string | Metric namespace \(e.g., AWS/EC2\) |
|
||||
| ↳ `metricName` | string | Metric name \(e.g., CPUUtilization\) |
|
||||
| ↳ `dimensions` | array | Array of name/value dimension pairs |
|
||||
|
||||
### `cloudwatch_get_metric_statistics`
|
||||
|
||||
@@ -170,8 +189,15 @@ Get statistics for a CloudWatch metric over a time range
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `label` | string | Metric label |
|
||||
| `datapoints` | array | Datapoints with timestamp and statistics values |
|
||||
| `label` | string | Metric label returned by CloudWatch |
|
||||
| `datapoints` | array | Datapoints sorted by timestamp with statistics values |
|
||||
| ↳ `timestamp` | number | Datapoint timestamp in epoch milliseconds |
|
||||
| ↳ `average` | number | Average statistic value |
|
||||
| ↳ `sum` | number | Sum statistic value |
|
||||
| ↳ `minimum` | number | Minimum statistic value |
|
||||
| ↳ `maximum` | number | Maximum statistic value |
|
||||
| ↳ `sampleCount` | number | Sample count statistic value |
|
||||
| ↳ `unit` | string | Unit of the metric |
|
||||
|
||||
### `cloudwatch_put_metric_data`
|
||||
|
||||
@@ -222,5 +248,13 @@ List and filter CloudWatch alarms
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `alarms` | array | List of CloudWatch alarms with state and configuration |
|
||||
| ↳ `alarmName` | string | Alarm name |
|
||||
| ↳ `alarmArn` | string | Alarm ARN |
|
||||
| ↳ `stateValue` | string | Current state \(OK, ALARM, INSUFFICIENT_DATA\) |
|
||||
| ↳ `stateReason` | string | Human-readable reason for the state |
|
||||
| ↳ `metricName` | string | Metric name \(MetricAlarm only\) |
|
||||
| ↳ `namespace` | string | Metric namespace \(MetricAlarm only\) |
|
||||
| ↳ `threshold` | number | Threshold value \(MetricAlarm only\) |
|
||||
| ↳ `stateUpdatedTimestamp` | number | Epoch ms when state last changed |
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Amazon DynamoDB
|
||||
description: Connect to Amazon DynamoDB
|
||||
description: Get, put, query, scan, update, and delete items in Amazon DynamoDB tables
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
@@ -55,7 +55,7 @@ Get an item from a DynamoDB table by primary key
|
||||
| `accessKeyId` | string | Yes | AWS access key ID |
|
||||
| `secretAccessKey` | string | Yes | AWS secret access key |
|
||||
| `tableName` | string | Yes | DynamoDB table name \(e.g., "Users", "Orders"\) |
|
||||
| `key` | object | Yes | Primary key of the item to retrieve \(e.g., \{"pk": "USER#123"\} or \{"pk": "ORDER#456", "sk": "ITEM#789"\}\) |
|
||||
| `key` | json | Yes | Primary key of the item to retrieve \(e.g., \{"pk": "USER#123"\} or \{"pk": "ORDER#456", "sk": "ITEM#789"\}\) |
|
||||
| `consistentRead` | boolean | No | Use strongly consistent read |
|
||||
|
||||
#### Output
|
||||
@@ -63,7 +63,7 @@ Get an item from a DynamoDB table by primary key
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `message` | string | Operation status message |
|
||||
| `item` | object | Retrieved item |
|
||||
| `item` | json | Retrieved item |
|
||||
|
||||
### `dynamodb_put`
|
||||
|
||||
@@ -77,14 +77,17 @@ Put an item into a DynamoDB table
|
||||
| `accessKeyId` | string | Yes | AWS access key ID |
|
||||
| `secretAccessKey` | string | Yes | AWS secret access key |
|
||||
| `tableName` | string | Yes | DynamoDB table name \(e.g., "Users", "Orders"\) |
|
||||
| `item` | object | Yes | Item to put into the table \(e.g., \{"pk": "USER#123", "name": "John", "email": "john@example.com"\}\) |
|
||||
| `item` | json | Yes | Item to put into the table \(e.g., \{"pk": "USER#123", "name": "John", "email": "john@example.com"\}\) |
|
||||
| `conditionExpression` | string | No | Condition that must be met for the put to succeed \(e.g., "attribute_not_exists\(pk\)" to prevent overwrites\) |
|
||||
| `expressionAttributeNames` | json | No | Attribute name mappings for reserved words used in conditionExpression \(e.g., \{"#name": "name"\}\) |
|
||||
| `expressionAttributeValues` | json | No | Expression attribute values used in conditionExpression \(e.g., \{":expected": "value"\}\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `message` | string | Operation status message |
|
||||
| `item` | object | Created item |
|
||||
| `item` | json | Created item |
|
||||
|
||||
### `dynamodb_query`
|
||||
|
||||
@@ -100,10 +103,12 @@ Query items from a DynamoDB table using key conditions
|
||||
| `tableName` | string | Yes | DynamoDB table name \(e.g., "Users", "Orders"\) |
|
||||
| `keyConditionExpression` | string | Yes | Key condition expression \(e.g., "pk = :pk" or "pk = :pk AND sk BEGINS_WITH :prefix"\) |
|
||||
| `filterExpression` | string | No | Filter expression for results \(e.g., "age > :minAge AND #status = :status"\) |
|
||||
| `expressionAttributeNames` | object | No | Attribute name mappings for reserved words \(e.g., \{"#status": "status"\}\) |
|
||||
| `expressionAttributeValues` | object | No | Expression attribute values \(e.g., \{":pk": "USER#123", ":minAge": 18\}\) |
|
||||
| `expressionAttributeNames` | json | No | Attribute name mappings for reserved words \(e.g., \{"#status": "status"\}\) |
|
||||
| `expressionAttributeValues` | json | No | Expression attribute values \(e.g., \{":pk": "USER#123", ":minAge": 18\}\) |
|
||||
| `indexName` | string | No | Secondary index name to query \(e.g., "GSI1", "email-index"\) |
|
||||
| `limit` | number | No | Maximum number of items to return \(e.g., 10, 50, 100\) |
|
||||
| `exclusiveStartKey` | json | No | Pagination token from a previous query's lastEvaluatedKey to continue fetching results |
|
||||
| `scanIndexForward` | boolean | No | Sort order for the sort key: true for ascending \(default\), false for descending |
|
||||
|
||||
#### Output
|
||||
|
||||
@@ -112,6 +117,7 @@ Query items from a DynamoDB table using key conditions
|
||||
| `message` | string | Operation status message |
|
||||
| `items` | array | Array of items returned |
|
||||
| `count` | number | Number of items returned |
|
||||
| `lastEvaluatedKey` | json | Pagination token to pass as exclusiveStartKey to fetch the next page of results |
|
||||
|
||||
### `dynamodb_scan`
|
||||
|
||||
@@ -127,9 +133,10 @@ Scan all items in a DynamoDB table
|
||||
| `tableName` | string | Yes | DynamoDB table name \(e.g., "Users", "Orders"\) |
|
||||
| `filterExpression` | string | No | Filter expression for results \(e.g., "age > :minAge AND #status = :status"\) |
|
||||
| `projectionExpression` | string | No | Attributes to retrieve \(e.g., "pk, sk, #name, email"\) |
|
||||
| `expressionAttributeNames` | object | No | Attribute name mappings for reserved words \(e.g., \{"#name": "name", "#status": "status"\}\) |
|
||||
| `expressionAttributeValues` | object | No | Expression attribute values \(e.g., \{":minAge": 18, ":status": "active"\}\) |
|
||||
| `expressionAttributeNames` | json | No | Attribute name mappings for reserved words \(e.g., \{"#name": "name", "#status": "status"\}\) |
|
||||
| `expressionAttributeValues` | json | No | Expression attribute values \(e.g., \{":minAge": 18, ":status": "active"\}\) |
|
||||
| `limit` | number | No | Maximum number of items to return \(e.g., 10, 50, 100\) |
|
||||
| `exclusiveStartKey` | json | No | Pagination token from a previous scan's lastEvaluatedKey to continue fetching results |
|
||||
|
||||
#### Output
|
||||
|
||||
@@ -138,6 +145,7 @@ Scan all items in a DynamoDB table
|
||||
| `message` | string | Operation status message |
|
||||
| `items` | array | Array of items returned |
|
||||
| `count` | number | Number of items returned |
|
||||
| `lastEvaluatedKey` | json | Pagination token to pass as exclusiveStartKey to fetch the next page of results |
|
||||
|
||||
### `dynamodb_update`
|
||||
|
||||
@@ -151,10 +159,10 @@ Update an item in a DynamoDB table
|
||||
| `accessKeyId` | string | Yes | AWS access key ID |
|
||||
| `secretAccessKey` | string | Yes | AWS secret access key |
|
||||
| `tableName` | string | Yes | DynamoDB table name \(e.g., "Users", "Orders"\) |
|
||||
| `key` | object | Yes | Primary key of the item to update \(e.g., \{"pk": "USER#123"\} or \{"pk": "ORDER#456", "sk": "ITEM#789"\}\) |
|
||||
| `key` | json | Yes | Primary key of the item to update \(e.g., \{"pk": "USER#123"\} or \{"pk": "ORDER#456", "sk": "ITEM#789"\}\) |
|
||||
| `updateExpression` | string | Yes | Update expression \(e.g., "SET #name = :name, age = :age" or "SET #count = #count + :inc"\) |
|
||||
| `expressionAttributeNames` | object | No | Attribute name mappings for reserved words \(e.g., \{"#name": "name", "#count": "count"\}\) |
|
||||
| `expressionAttributeValues` | object | No | Expression attribute values \(e.g., \{":name": "John", ":age": 30, ":inc": 1\}\) |
|
||||
| `expressionAttributeNames` | json | No | Attribute name mappings for reserved words \(e.g., \{"#name": "name", "#count": "count"\}\) |
|
||||
| `expressionAttributeValues` | json | No | Expression attribute values \(e.g., \{":name": "John", ":age": 30, ":inc": 1\}\) |
|
||||
| `conditionExpression` | string | No | Condition that must be met for the update to succeed \(e.g., "attribute_exists\(pk\)" or "version = :expectedVersion"\) |
|
||||
|
||||
#### Output
|
||||
@@ -162,7 +170,7 @@ Update an item in a DynamoDB table
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `message` | string | Operation status message |
|
||||
| `item` | object | Updated item |
|
||||
| `item` | json | Updated item with all attributes |
|
||||
|
||||
### `dynamodb_delete`
|
||||
|
||||
@@ -176,8 +184,10 @@ Delete an item from a DynamoDB table
|
||||
| `accessKeyId` | string | Yes | AWS access key ID |
|
||||
| `secretAccessKey` | string | Yes | AWS secret access key |
|
||||
| `tableName` | string | Yes | DynamoDB table name \(e.g., "Users", "Orders"\) |
|
||||
| `key` | object | Yes | Primary key of the item to delete \(e.g., \{"pk": "USER#123"\} or \{"pk": "ORDER#456", "sk": "ITEM#789"\}\) |
|
||||
| `key` | json | Yes | Primary key of the item to delete \(e.g., \{"pk": "USER#123"\} or \{"pk": "ORDER#456", "sk": "ITEM#789"\}\) |
|
||||
| `conditionExpression` | string | No | Condition that must be met for the delete to succeed \(e.g., "attribute_exists\(pk\)"\) |
|
||||
| `expressionAttributeNames` | json | No | Attribute name mappings for reserved words used in conditionExpression \(e.g., \{"#status": "status"\}\) |
|
||||
| `expressionAttributeValues` | json | No | Expression attribute values used in conditionExpression \(e.g., \{":status": "active"\}\) |
|
||||
|
||||
#### Output
|
||||
|
||||
@@ -204,6 +214,6 @@ Introspect DynamoDB to list tables or get detailed schema information for a spec
|
||||
| --------- | ---- | ----------- |
|
||||
| `message` | string | Operation status message |
|
||||
| `tables` | array | List of table names in the region |
|
||||
| `tableDetails` | object | Detailed schema information for a specific table |
|
||||
| `tableDetails` | json | Detailed schema information for a specific table |
|
||||
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ Get detailed information about an IAM user
|
||||
| `region` | string | Yes | AWS region \(e.g., us-east-1\) |
|
||||
| `accessKeyId` | string | Yes | AWS access key ID |
|
||||
| `secretAccessKey` | string | Yes | AWS secret access key |
|
||||
| `userName` | string | Yes | The name of the IAM user to retrieve |
|
||||
| `userName` | string | No | The name of the IAM user to retrieve \(defaults to the caller if omitted\) |
|
||||
|
||||
#### Output
|
||||
|
||||
@@ -440,4 +440,80 @@ Remove an IAM user from a group
|
||||
| --------- | ---- | ----------- |
|
||||
| `message` | string | Operation status message |
|
||||
|
||||
### `iam_list_attached_role_policies`
|
||||
|
||||
List all managed policies attached to an IAM role
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `region` | string | Yes | AWS region \(e.g., us-east-1\) |
|
||||
| `accessKeyId` | string | Yes | AWS access key ID |
|
||||
| `secretAccessKey` | string | Yes | AWS secret access key |
|
||||
| `roleName` | string | Yes | Name of the IAM role |
|
||||
| `pathPrefix` | string | No | Path prefix to filter policies \(e.g., /application/\) |
|
||||
| `maxItems` | number | No | Maximum number of policies to return \(1-1000\) |
|
||||
| `marker` | string | No | Pagination marker from a previous request |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `attachedPolicies` | json | List of attached policies with policyName and policyArn |
|
||||
| `isTruncated` | boolean | Whether there are more results available |
|
||||
| `marker` | string | Pagination marker for the next page of results |
|
||||
| `count` | number | Number of attached policies returned |
|
||||
|
||||
### `iam_list_attached_user_policies`
|
||||
|
||||
List all managed policies attached to an IAM user
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `region` | string | Yes | AWS region \(e.g., us-east-1\) |
|
||||
| `accessKeyId` | string | Yes | AWS access key ID |
|
||||
| `secretAccessKey` | string | Yes | AWS secret access key |
|
||||
| `userName` | string | Yes | Name of the IAM user |
|
||||
| `pathPrefix` | string | No | Path prefix to filter policies \(e.g., /application/\) |
|
||||
| `maxItems` | number | No | Maximum number of policies to return \(1-1000\) |
|
||||
| `marker` | string | No | Pagination marker from a previous request |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `attachedPolicies` | json | List of attached policies with policyName and policyArn |
|
||||
| `isTruncated` | boolean | Whether there are more results available |
|
||||
| `marker` | string | Pagination marker for the next page of results |
|
||||
| `count` | number | Number of attached policies returned |
|
||||
|
||||
### `iam_simulate_principal_policy`
|
||||
|
||||
Simulate whether a user, role, or group is allowed to perform specific AWS actions — useful for pre-flight access checks
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `region` | string | Yes | AWS region \(e.g., us-east-1\) |
|
||||
| `accessKeyId` | string | Yes | AWS access key ID |
|
||||
| `secretAccessKey` | string | Yes | AWS secret access key |
|
||||
| `policySourceArn` | string | Yes | ARN of the user, group, or role to simulate \(e.g., arn:aws:iam::123456789012:user/alice\) |
|
||||
| `actionNames` | string | Yes | Comma-separated list of AWS actions to simulate \(e.g., s3:GetObject,ec2:DescribeInstances\) |
|
||||
| `resourceArns` | string | No | Comma-separated list of resource ARNs to simulate against \(defaults to * if not provided\) |
|
||||
| `maxResults` | number | No | Maximum number of simulation results to return \(1-1000\) |
|
||||
| `marker` | string | No | Pagination marker from a previous request |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `evaluationResults` | json | Simulation results per action: evalActionName, evalResourceName, evalDecision \(allowed/explicitDeny/implicitDeny\), matchedStatements \(sourcePolicyId, sourcePolicyType\), missingContextValues |
|
||||
| `isTruncated` | boolean | Whether there are more results available |
|
||||
| `marker` | string | Pagination marker for the next page of results |
|
||||
| `count` | number | Number of evaluation results returned |
|
||||
|
||||
|
||||
|
||||
340
apps/docs/content/docs/en/tools/identity_center.mdx
Normal file
@@ -0,0 +1,340 @@
|
||||
---
|
||||
title: AWS Identity Center
|
||||
description: Manage temporary elevated access in AWS IAM Identity Center
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="identity_center"
|
||||
color="linear-gradient(45deg, #BD0816 0%, #FF5252 100%)"
|
||||
/>
|
||||
|
||||
{/* MANUAL-CONTENT-START:intro */}
|
||||
[AWS IAM Identity Center](https://aws.amazon.com/iam/identity-center/) (formerly AWS Single Sign-On) is the recommended service for managing workforce access to multiple AWS accounts and applications. It provides a central place to assign users and groups temporary, permission-scoped access to AWS accounts using permission sets — without creating long-lived IAM credentials.
|
||||
|
||||
With AWS IAM Identity Center, you can:
|
||||
|
||||
- **Provision account assignments**: Grant a user or group access to a specific AWS account with a specific permission set — the core primitive of temporary elevated access
|
||||
- **Revoke access on demand**: Delete account assignments to immediately remove elevated permissions when they are no longer needed
|
||||
- **Look up users by email**: Resolve a federated identity (email address) to an Identity Store user ID for programmatic access provisioning
|
||||
- **List permission sets**: Enumerate the available permission sets (e.g., ReadOnly, PowerUser, AdministratorAccess) defined in your Identity Center instance
|
||||
- **Monitor assignment status**: Poll the provisioning status of create/delete operations, which are asynchronous in AWS
|
||||
- **List accounts in your organization**: Enumerate all AWS accounts in your AWS Organizations structure to populate access request dropdowns
|
||||
- **Manage groups**: List groups and resolve group IDs by display name for group-based access grants
|
||||
|
||||
In Sim, the AWS Identity Center integration is designed to power **TEAM (Temporary Elevated Access Management)** workflows — automated pipelines where users request elevated access, approvers approve or deny it, access is provisioned with a time limit, and auto-revocation removes it when the window expires. This replaces manual console-based access management with auditable, agent-driven workflows that integrate with Slack, email, ticketing systems, and CloudTrail for full traceability.
|
||||
{/* MANUAL-CONTENT-END */}
|
||||
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Provision and revoke temporary access to AWS accounts via IAM Identity Center (SSO). Assign permission sets to users or groups, look up users by email, and list accounts and permission sets for access request workflows.
|
||||
|
||||
|
||||
|
||||
## Tools
|
||||
|
||||
### `identity_center_list_instances`
|
||||
|
||||
List all AWS IAM Identity Center instances in your account
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `region` | string | Yes | AWS region \(e.g., us-east-1\) |
|
||||
| `accessKeyId` | string | Yes | AWS access key ID |
|
||||
| `secretAccessKey` | string | Yes | AWS secret access key |
|
||||
| `maxResults` | number | No | Maximum number of instances to return \(1-100\) |
|
||||
| `nextToken` | string | No | Pagination token from a previous request |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `instances` | json | List of Identity Center instances with instanceArn, identityStoreId, name, status, statusReason |
|
||||
| `nextToken` | string | Pagination token for the next page of results |
|
||||
| `count` | number | Number of instances returned |
|
||||
|
||||
### `identity_center_list_accounts`
|
||||
|
||||
List all AWS accounts in your organization
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `region` | string | Yes | AWS region \(e.g., us-east-1\) |
|
||||
| `accessKeyId` | string | Yes | AWS access key ID |
|
||||
| `secretAccessKey` | string | Yes | AWS secret access key |
|
||||
| `maxResults` | number | No | Maximum number of accounts to return |
|
||||
| `nextToken` | string | No | Pagination token from a previous request |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `accounts` | json | List of AWS accounts with id, arn, name, email, status |
|
||||
| `nextToken` | string | Pagination token for the next page of results |
|
||||
| `count` | number | Number of accounts returned |
|
||||
|
||||
### `identity_center_describe_account`
|
||||
|
||||
Retrieve details about a specific AWS account by its ID
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `region` | string | Yes | AWS region \(e.g., us-east-1\) |
|
||||
| `accessKeyId` | string | Yes | AWS access key ID |
|
||||
| `secretAccessKey` | string | Yes | AWS secret access key |
|
||||
| `accountId` | string | Yes | AWS account ID to describe |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `id` | string | AWS account ID |
|
||||
| `arn` | string | AWS account ARN |
|
||||
| `name` | string | Account name |
|
||||
| `email` | string | Root email address of the account |
|
||||
| `status` | string | Account status \(ACTIVE, SUSPENDED, etc.\) |
|
||||
| `joinedTimestamp` | string | Date the account joined the organization |
|
||||
|
||||
### `identity_center_list_permission_sets`
|
||||
|
||||
List all permission sets defined in an IAM Identity Center instance
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `region` | string | Yes | AWS region \(e.g., us-east-1\) |
|
||||
| `accessKeyId` | string | Yes | AWS access key ID |
|
||||
| `secretAccessKey` | string | Yes | AWS secret access key |
|
||||
| `instanceArn` | string | Yes | ARN of the Identity Center instance |
|
||||
| `maxResults` | number | No | Maximum number of permission sets to return |
|
||||
| `nextToken` | string | No | Pagination token from a previous request |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `permissionSets` | json | List of permission sets with permissionSetArn, name, description, sessionDuration |
|
||||
| `nextToken` | string | Pagination token for the next page of results |
|
||||
| `count` | number | Number of permission sets returned |
|
||||
|
||||
### `identity_center_get_user`
|
||||
|
||||
Look up a user in the Identity Store by email address
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `region` | string | Yes | AWS region \(e.g., us-east-1\) |
|
||||
| `accessKeyId` | string | Yes | AWS access key ID |
|
||||
| `secretAccessKey` | string | Yes | AWS secret access key |
|
||||
| `identityStoreId` | string | Yes | Identity Store ID \(from the Identity Center instance\) |
|
||||
| `email` | string | Yes | Email address of the user to look up |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `userId` | string | Identity Store user ID \(use as principalId\) |
|
||||
| `userName` | string | Username in the Identity Store |
|
||||
| `displayName` | string | Display name of the user |
|
||||
| `email` | string | Email address of the user |
|
||||
|
||||
### `identity_center_get_group`
|
||||
|
||||
Look up a group in the Identity Store by display name
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `region` | string | Yes | AWS region \(e.g., us-east-1\) |
|
||||
| `accessKeyId` | string | Yes | AWS access key ID |
|
||||
| `secretAccessKey` | string | Yes | AWS secret access key |
|
||||
| `identityStoreId` | string | Yes | Identity Store ID \(from the Identity Center instance\) |
|
||||
| `displayName` | string | Yes | Display name of the group to look up |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `groupId` | string | Identity Store group ID \(use as principalId\) |
|
||||
| `displayName` | string | Display name of the group |
|
||||
| `description` | string | Group description |
|
||||
|
||||
### `identity_center_list_groups`
|
||||
|
||||
List all groups in the Identity Store
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `region` | string | Yes | AWS region \(e.g., us-east-1\) |
|
||||
| `accessKeyId` | string | Yes | AWS access key ID |
|
||||
| `secretAccessKey` | string | Yes | AWS secret access key |
|
||||
| `identityStoreId` | string | Yes | Identity Store ID \(from the Identity Center instance\) |
|
||||
| `maxResults` | number | No | Maximum number of groups to return |
|
||||
| `nextToken` | string | No | Pagination token from a previous request |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `groups` | json | List of groups with groupId, displayName, description |
|
||||
| `nextToken` | string | Pagination token for the next page of results |
|
||||
| `count` | number | Number of groups returned |
|
||||
|
||||
### `identity_center_create_account_assignment`
|
||||
|
||||
Grant a user or group access to an AWS account via a permission set (temporary elevated access)
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `region` | string | Yes | AWS region \(e.g., us-east-1\) |
|
||||
| `accessKeyId` | string | Yes | AWS access key ID |
|
||||
| `secretAccessKey` | string | Yes | AWS secret access key |
|
||||
| `instanceArn` | string | Yes | ARN of the Identity Center instance |
|
||||
| `accountId` | string | Yes | AWS account ID to grant access to |
|
||||
| `permissionSetArn` | string | Yes | ARN of the permission set to assign |
|
||||
| `principalType` | string | Yes | Type of principal: USER or GROUP |
|
||||
| `principalId` | string | Yes | Identity Store ID of the user or group |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `message` | string | Status message |
|
||||
| `status` | string | Provisioning status: IN_PROGRESS, FAILED, or SUCCEEDED |
|
||||
| `requestId` | string | Request ID to use with Check Assignment Status |
|
||||
| `accountId` | string | Target AWS account ID |
|
||||
| `permissionSetArn` | string | Permission set ARN |
|
||||
| `principalType` | string | Principal type \(USER or GROUP\) |
|
||||
| `principalId` | string | Principal ID |
|
||||
| `failureReason` | string | Reason for failure if status is FAILED |
|
||||
| `createdDate` | string | Date the request was created |
|
||||
|
||||
### `identity_center_delete_account_assignment`
|
||||
|
||||
Revoke a user or group access to an AWS account by removing a permission set assignment
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `region` | string | Yes | AWS region \(e.g., us-east-1\) |
|
||||
| `accessKeyId` | string | Yes | AWS access key ID |
|
||||
| `secretAccessKey` | string | Yes | AWS secret access key |
|
||||
| `instanceArn` | string | Yes | ARN of the Identity Center instance |
|
||||
| `accountId` | string | Yes | AWS account ID to revoke access from |
|
||||
| `permissionSetArn` | string | Yes | ARN of the permission set to remove |
|
||||
| `principalType` | string | Yes | Type of principal: USER or GROUP |
|
||||
| `principalId` | string | Yes | Identity Store ID of the user or group |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `message` | string | Status message |
|
||||
| `status` | string | Deprovisioning status: IN_PROGRESS, FAILED, or SUCCEEDED |
|
||||
| `requestId` | string | Request ID to use with Check Assignment Status |
|
||||
| `accountId` | string | Target AWS account ID |
|
||||
| `permissionSetArn` | string | Permission set ARN |
|
||||
| `principalType` | string | Principal type \(USER or GROUP\) |
|
||||
| `principalId` | string | Principal ID |
|
||||
| `failureReason` | string | Reason for failure if status is FAILED |
|
||||
| `createdDate` | string | Date the request was created |
|
||||
|
||||
### `identity_center_check_assignment_status`
|
||||
|
||||
Check the provisioning status of an account assignment creation request
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `region` | string | Yes | AWS region \(e.g., us-east-1\) |
|
||||
| `accessKeyId` | string | Yes | AWS access key ID |
|
||||
| `secretAccessKey` | string | Yes | AWS secret access key |
|
||||
| `instanceArn` | string | Yes | ARN of the Identity Center instance |
|
||||
| `requestId` | string | Yes | Request ID returned from Create or Delete Account Assignment |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `message` | string | Human-readable status message |
|
||||
| `status` | string | Current status: IN_PROGRESS, FAILED, or SUCCEEDED |
|
||||
| `requestId` | string | The request ID that was checked |
|
||||
| `accountId` | string | Target AWS account ID |
|
||||
| `permissionSetArn` | string | Permission set ARN |
|
||||
| `principalType` | string | Principal type \(USER or GROUP\) |
|
||||
| `principalId` | string | Principal ID |
|
||||
| `failureReason` | string | Reason for failure if status is FAILED |
|
||||
| `createdDate` | string | Date the request was created |
|
||||
|
||||
### `identity_center_check_assignment_deletion_status`
|
||||
|
||||
Check the deprovisioning status of an account assignment deletion request
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `region` | string | Yes | AWS region \(e.g., us-east-1\) |
|
||||
| `accessKeyId` | string | Yes | AWS access key ID |
|
||||
| `secretAccessKey` | string | Yes | AWS secret access key |
|
||||
| `instanceArn` | string | Yes | ARN of the Identity Center instance |
|
||||
| `requestId` | string | Yes | Request ID returned from Delete Account Assignment |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `message` | string | Human-readable status message |
|
||||
| `status` | string | Current deletion status: IN_PROGRESS, FAILED, or SUCCEEDED |
|
||||
| `requestId` | string | The deletion request ID that was checked |
|
||||
| `accountId` | string | Target AWS account ID |
|
||||
| `permissionSetArn` | string | Permission set ARN |
|
||||
| `principalType` | string | Principal type \(USER or GROUP\) |
|
||||
| `principalId` | string | Principal ID |
|
||||
| `failureReason` | string | Reason for failure if status is FAILED |
|
||||
| `createdDate` | string | Date the request was created |
|
||||
|
||||
### `identity_center_list_account_assignments`
|
||||
|
||||
List all account assignments for a specific user or group across all accounts
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `region` | string | Yes | AWS region \(e.g., us-east-1\) |
|
||||
| `accessKeyId` | string | Yes | AWS access key ID |
|
||||
| `secretAccessKey` | string | Yes | AWS secret access key |
|
||||
| `instanceArn` | string | Yes | ARN of the Identity Center instance |
|
||||
| `principalId` | string | Yes | Identity Store ID of the user or group |
|
||||
| `principalType` | string | Yes | Type of principal: USER or GROUP |
|
||||
| `maxResults` | number | No | Maximum number of assignments to return |
|
||||
| `nextToken` | string | No | Pagination token from a previous request |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `assignments` | json | List of account assignments with accountId, permissionSetArn, principalType, principalId |
|
||||
| `nextToken` | string | Pagination token for the next page of results |
|
||||
| `count` | number | Number of assignments returned |
|
||||
|
||||
|
||||
@@ -86,6 +86,7 @@
|
||||
"huggingface",
|
||||
"hunter",
|
||||
"iam",
|
||||
"identity_center",
|
||||
"image_generator",
|
||||
"imap",
|
||||
"incidentio",
|
||||
@@ -154,6 +155,7 @@
|
||||
"sentry",
|
||||
"serper",
|
||||
"servicenow",
|
||||
"ses",
|
||||
"sftp",
|
||||
"sharepoint",
|
||||
"shopify",
|
||||
|
||||
241
apps/docs/content/docs/en/tools/ses.mdx
Normal file
@@ -0,0 +1,241 @@
|
||||
---
|
||||
title: AWS SES
|
||||
description: Send emails and manage templates with AWS Simple Email Service
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="ses"
|
||||
color="linear-gradient(45deg, #BD0816 0%, #FF5252 100%)"
|
||||
/>
|
||||
|
||||
{/* MANUAL-CONTENT-START:intro */}
|
||||
[Amazon Simple Email Service (SES)](https://aws.amazon.com/ses/) is a cloud-based email sending service designed for high-volume, transactional, and marketing email delivery. It provides a cost-effective, scalable way to send email without managing your own mail server infrastructure.
|
||||
|
||||
With AWS SES, you can:
|
||||
|
||||
- **Send simple emails**: Deliver one-off emails with plain text or HTML body content to individual recipients
|
||||
- **Send templated emails**: Use pre-defined templates with variable substitution (e.g., `{{name}}`, `{{link}}`) for personalized emails at scale
|
||||
- **Send bulk emails**: Deliver templated emails to large lists of recipients in a single API call, with per-destination data overrides
|
||||
- **Manage email templates**: Create, retrieve, list, and delete reusable email templates for transactional and marketing campaigns
|
||||
- **Monitor account health**: Retrieve your account's sending quota, send rate, and whether sending is currently enabled
|
||||
|
||||
In Sim, the AWS SES integration is designed for workflows that need reliable, programmatic email delivery — from access request notifications and approval alerts to bulk outreach and automated reporting. It pairs naturally with the IAM Identity Center integration for TEAM (Temporary Elevated Access Management) workflows, where email notifications are sent when access is provisioned, approved, or revoked.
|
||||
{/* MANUAL-CONTENT-END */}
|
||||
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Integrate AWS SES v2 into the workflow. Send simple, templated, and bulk emails. Manage email templates and retrieve account sending quota and verified identity information.
|
||||
|
||||
|
||||
|
||||
## Tools
|
||||
|
||||
### `ses_send_email`
|
||||
|
||||
Send an email via AWS SES using simple or HTML content
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `region` | string | Yes | AWS region \(e.g., us-east-1\) |
|
||||
| `accessKeyId` | string | Yes | AWS access key ID |
|
||||
| `secretAccessKey` | string | Yes | AWS secret access key |
|
||||
| `fromAddress` | string | Yes | Verified sender email address |
|
||||
| `toAddresses` | string | Yes | Comma-separated list of recipient email addresses |
|
||||
| `subject` | string | Yes | Email subject line |
|
||||
| `bodyText` | string | No | Plain text email body |
|
||||
| `bodyHtml` | string | No | HTML email body |
|
||||
| `ccAddresses` | string | No | Comma-separated list of CC email addresses |
|
||||
| `bccAddresses` | string | No | Comma-separated list of BCC email addresses |
|
||||
| `replyToAddresses` | string | No | Comma-separated list of reply-to email addresses |
|
||||
| `configurationSetName` | string | No | SES configuration set name for tracking |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `messageId` | string | SES message ID for the sent email |
|
||||
|
||||
### `ses_send_templated_email`
|
||||
|
||||
Send an email using an SES email template with dynamic template data
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `region` | string | Yes | AWS region \(e.g., us-east-1\) |
|
||||
| `accessKeyId` | string | Yes | AWS access key ID |
|
||||
| `secretAccessKey` | string | Yes | AWS secret access key |
|
||||
| `fromAddress` | string | Yes | Verified sender email address |
|
||||
| `toAddresses` | string | Yes | Comma-separated list of recipient email addresses |
|
||||
| `templateName` | string | Yes | Name of the SES email template to use |
|
||||
| `templateData` | string | Yes | JSON string of key-value pairs for template variable substitution |
|
||||
| `ccAddresses` | string | No | Comma-separated list of CC email addresses |
|
||||
| `bccAddresses` | string | No | Comma-separated list of BCC email addresses |
|
||||
| `configurationSetName` | string | No | SES configuration set name for tracking |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `messageId` | string | SES message ID for the sent email |
|
||||
|
||||
### `ses_send_bulk_email`
|
||||
|
||||
Send emails to multiple recipients using an SES template with per-recipient data
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `region` | string | Yes | AWS region \(e.g., us-east-1\) |
|
||||
| `accessKeyId` | string | Yes | AWS access key ID |
|
||||
| `secretAccessKey` | string | Yes | AWS secret access key |
|
||||
| `fromAddress` | string | Yes | Verified sender email address |
|
||||
| `templateName` | string | Yes | Name of the SES email template to use |
|
||||
| `destinations` | string | Yes | JSON array of destination objects with toAddresses \(string\[\]\) and optional templateData \(JSON string\); falls back to defaultTemplateData when omitted |
|
||||
| `defaultTemplateData` | string | No | Default JSON template data used when a destination does not specify its own |
|
||||
| `configurationSetName` | string | No | SES configuration set name for tracking |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `results` | array | Per-destination send results with status and messageId |
|
||||
| `successCount` | number | Number of successfully sent emails |
|
||||
| `failureCount` | number | Number of failed email sends |
|
||||
|
||||
### `ses_list_identities`
|
||||
|
||||
List all verified email identities (email addresses and domains) in your SES account
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `region` | string | Yes | AWS region \(e.g., us-east-1\) |
|
||||
| `accessKeyId` | string | Yes | AWS access key ID |
|
||||
| `secretAccessKey` | string | Yes | AWS secret access key |
|
||||
| `pageSize` | number | No | Maximum number of identities to return \(1-1000\) |
|
||||
| `nextToken` | string | No | Pagination token from a previous list response |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `identities` | array | List of email identities with name, type, sending status, and verification status |
|
||||
| `nextToken` | string | Pagination token for the next page of results |
|
||||
| `count` | number | Number of identities returned |
|
||||
|
||||
### `ses_get_account`
|
||||
|
||||
Get SES account sending quota and status information
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `region` | string | Yes | AWS region \(e.g., us-east-1\) |
|
||||
| `accessKeyId` | string | Yes | AWS access key ID |
|
||||
| `secretAccessKey` | string | Yes | AWS secret access key |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `sendingEnabled` | boolean | Whether email sending is enabled for the account |
|
||||
| `max24HourSend` | number | Maximum emails allowed per 24-hour period |
|
||||
| `maxSendRate` | number | Maximum emails allowed per second |
|
||||
| `sentLast24Hours` | number | Number of emails sent in the last 24 hours |
|
||||
|
||||
### `ses_create_template`
|
||||
|
||||
Create a new SES email template for use with templated email sending
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `region` | string | Yes | AWS region \(e.g., us-east-1\) |
|
||||
| `accessKeyId` | string | Yes | AWS access key ID |
|
||||
| `secretAccessKey` | string | Yes | AWS secret access key |
|
||||
| `templateName` | string | Yes | Unique name for the email template |
|
||||
| `subjectPart` | string | Yes | Subject line template \(supports \{\{variable\}\} substitution\) |
|
||||
| `textPart` | string | No | Plain text version of the template body |
|
||||
| `htmlPart` | string | No | HTML version of the template body |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `message` | string | Confirmation message for the created template |
|
||||
|
||||
### `ses_get_template`
|
||||
|
||||
Retrieve the content and details of an SES email template
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `region` | string | Yes | AWS region \(e.g., us-east-1\) |
|
||||
| `accessKeyId` | string | Yes | AWS access key ID |
|
||||
| `secretAccessKey` | string | Yes | AWS secret access key |
|
||||
| `templateName` | string | Yes | Name of the template to retrieve |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `templateName` | string | Name of the template |
|
||||
| `subjectPart` | string | Subject line of the template |
|
||||
| `textPart` | string | Plain text body of the template |
|
||||
| `htmlPart` | string | HTML body of the template |
|
||||
|
||||
### `ses_list_templates`
|
||||
|
||||
List all SES email templates in your account
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `region` | string | Yes | AWS region \(e.g., us-east-1\) |
|
||||
| `accessKeyId` | string | Yes | AWS access key ID |
|
||||
| `secretAccessKey` | string | Yes | AWS secret access key |
|
||||
| `pageSize` | number | No | Maximum number of templates to return |
|
||||
| `nextToken` | string | No | Pagination token from a previous list response |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `templates` | array | List of email templates with name and creation timestamp |
|
||||
| `nextToken` | string | Pagination token for the next page of results |
|
||||
| `count` | number | Number of templates returned |
|
||||
|
||||
### `ses_delete_template`
|
||||
|
||||
Delete an existing SES email template
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `region` | string | Yes | AWS region \(e.g., us-east-1\) |
|
||||
| `accessKeyId` | string | Yes | AWS access key ID |
|
||||
| `secretAccessKey` | string | Yes | AWS secret access key |
|
||||
| `templateName` | string | Yes | Name of the template to delete |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `message` | string | Confirmation message for the deleted template |
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ Assume an IAM role and receive temporary security credentials
|
||||
| `roleArn` | string | Yes | ARN of the IAM role to assume |
|
||||
| `roleSessionName` | string | Yes | Identifier for the assumed role session |
|
||||
| `durationSeconds` | number | No | Duration of the session in seconds \(900-43200, default 3600\) |
|
||||
| `policy` | string | No | JSON IAM policy to further restrict session permissions \(max 2048 chars\) |
|
||||
| `externalId` | string | No | External ID for cross-account access |
|
||||
| `serialNumber` | string | No | MFA device serial number or ARN |
|
||||
| `tokenCode` | string | No | MFA token code \(6 digits\) |
|
||||
@@ -61,6 +62,7 @@ Assume an IAM role and receive temporary security credentials
|
||||
| `assumedRoleArn` | string | ARN of the assumed role |
|
||||
| `assumedRoleId` | string | Assumed role ID with session name |
|
||||
| `packedPolicySize` | number | Percentage of allowed policy size used |
|
||||
| `sourceIdentity` | string | Source identity set on the role session, if any |
|
||||
|
||||
### `sts_get_caller_identity`
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ Trigger workflow when a Fireflies meeting transcription is complete
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `meetingId` | string | The ID of the transcribed meeting |
|
||||
| `eventType` | string | The type of event \(Transcription completed\) |
|
||||
| `eventType` | string | The type of event \(e.g. Transcription completed, meeting.transcribed\) |
|
||||
| `clientReferenceId` | string | Custom reference ID if set during upload |
|
||||
| `timestamp` | number | Unix timestamp in milliseconds when the event was fired \(V2 webhooks\) |
|
||||
|
||||
|
||||
@@ -304,7 +304,7 @@ Trigger workflow on any Jira Service Management webhook event
|
||||
| ↳ `id` | string | Changelog ID |
|
||||
| `comment` | object | comment output from the tool |
|
||||
| ↳ `id` | string | Comment ID |
|
||||
| ↳ `body` | string | Comment text/body |
|
||||
| ↳ `body` | json | Comment body in Atlassian Document Format \(ADF\). On Jira Server this may be a plain string. |
|
||||
| ↳ `author` | object | author output from the tool |
|
||||
| ↳ `displayName` | string | Comment author display name |
|
||||
| ↳ `accountId` | string | Comment author account ID |
|
||||
|
||||
@@ -25,6 +25,7 @@ Trigger workflow from Slack events like mentions, messages, and reactions
|
||||
| `signingSecret` | string | Yes | The signing secret from your Slack app to validate request authenticity. |
|
||||
| `botToken` | string | No | The bot token from your Slack app. Required for downloading files attached to messages. |
|
||||
| `includeFiles` | boolean | No | Download and include file attachments from messages. Requires a bot token with files:read scope. |
|
||||
| `setupWizard` | modal | No | Walk through manifest creation, app install, and pasting credentials. |
|
||||
|
||||
#### Output
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"@vercel/og": "^0.6.5",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"drizzle-orm": "^0.44.5",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"fumadocs-core": "16.6.7",
|
||||
"fumadocs-mdx": "14.2.8",
|
||||
"fumadocs-openapi": "10.3.13",
|
||||
|
||||
BIN
apps/docs/public/static/enterprise/access-control-blocks.png
Normal file
|
After Width: | Height: | Size: 504 KiB |
BIN
apps/docs/public/static/enterprise/access-control-groups.png
Normal file
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 169 KiB |
BIN
apps/docs/public/static/enterprise/access-control-platform.png
Normal file
|
After Width: | Height: | Size: 197 KiB |
BIN
apps/docs/public/static/enterprise/audit-logs.png
Normal file
|
After Width: | Height: | Size: 488 KiB |
BIN
apps/docs/public/static/enterprise/data-retention.png
Normal file
|
After Width: | Height: | Size: 105 KiB |
BIN
apps/docs/public/static/enterprise/sso-form.png
Normal file
|
After Width: | Height: | Size: 209 KiB |
BIN
apps/docs/public/static/enterprise/whitelabeling.png
Normal file
|
After Width: | Height: | Size: 236 KiB |
@@ -10,6 +10,7 @@ import { usePostHog } from 'posthog-js/react'
|
||||
import { Input, Label } from '@/components/emcn'
|
||||
import { client, useSession } from '@/lib/auth/auth-client'
|
||||
import { getEnv, isFalsy, isTruthy } from '@/lib/core/config/env'
|
||||
import { validateCallbackUrl } from '@/lib/core/security/input-validation'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||
import { captureClientEvent, captureEvent } from '@/lib/posthog/client'
|
||||
@@ -102,10 +103,14 @@ function SignupFormContent({ githubAvailable, googleAvailable, isProduction }: S
|
||||
useEffect(() => {
|
||||
setTurnstileSiteKey(getEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY'))
|
||||
}, [])
|
||||
const redirectUrl = useMemo(
|
||||
() => searchParams.get('redirect') || searchParams.get('callbackUrl') || '',
|
||||
[searchParams]
|
||||
)
|
||||
const rawRedirectUrl = searchParams.get('redirect') || searchParams.get('callbackUrl') || ''
|
||||
const isValidRedirectUrl = rawRedirectUrl ? validateCallbackUrl(rawRedirectUrl) : false
|
||||
const invalidCallbackRef = useRef(false)
|
||||
if (rawRedirectUrl && !isValidRedirectUrl && !invalidCallbackRef.current) {
|
||||
invalidCallbackRef.current = true
|
||||
logger.warn('Invalid callback URL detected and blocked:', { url: rawRedirectUrl })
|
||||
}
|
||||
const redirectUrl = isValidRedirectUrl ? rawRedirectUrl : ''
|
||||
const isInviteFlow = useMemo(
|
||||
() =>
|
||||
searchParams.get('invite_flow') === 'true' ||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { client, useSession } from '@/lib/auth/auth-client'
|
||||
import { validateCallbackUrl } from '@/lib/core/security/input-validation'
|
||||
|
||||
const logger = createLogger('useVerification')
|
||||
|
||||
@@ -55,8 +56,11 @@ export function useVerification({
|
||||
}
|
||||
|
||||
const storedRedirectUrl = sessionStorage.getItem('inviteRedirectUrl')
|
||||
if (storedRedirectUrl) {
|
||||
if (storedRedirectUrl && validateCallbackUrl(storedRedirectUrl)) {
|
||||
setRedirectUrl(storedRedirectUrl)
|
||||
} else if (storedRedirectUrl) {
|
||||
logger.warn('Ignoring unsafe stored invite redirect URL', { url: storedRedirectUrl })
|
||||
sessionStorage.removeItem('inviteRedirectUrl')
|
||||
}
|
||||
|
||||
const storedIsInviteFlow = sessionStorage.getItem('isInviteFlow')
|
||||
@@ -67,7 +71,11 @@ export function useVerification({
|
||||
|
||||
const redirectParam = searchParams.get('redirectAfter')
|
||||
if (redirectParam) {
|
||||
setRedirectUrl(redirectParam)
|
||||
if (validateCallbackUrl(redirectParam)) {
|
||||
setRedirectUrl(redirectParam)
|
||||
} else {
|
||||
logger.warn('Ignoring unsafe redirectAfter parameter', { url: redirectParam })
|
||||
}
|
||||
}
|
||||
|
||||
const inviteFlowParam = searchParams.get('invite_flow')
|
||||
|
||||
82
apps/sim/app/(landing)/components/contact/consts.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { z } from 'zod'
|
||||
import { NO_EMAIL_HEADER_CONTROL_CHARS_REGEX } from '@/lib/messaging/email/utils'
|
||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||
|
||||
export const CONTACT_TOPIC_VALUES = [
|
||||
'general',
|
||||
'support',
|
||||
'integration',
|
||||
'feature_request',
|
||||
'sales',
|
||||
'partnership',
|
||||
'billing',
|
||||
'other',
|
||||
] as const
|
||||
|
||||
export const CONTACT_TOPIC_OPTIONS = [
|
||||
{ value: 'general', label: 'General question' },
|
||||
{ value: 'support', label: 'Technical support' },
|
||||
{ value: 'integration', label: 'Integration request' },
|
||||
{ value: 'feature_request', label: 'Feature request' },
|
||||
{ value: 'sales', label: 'Sales & pricing' },
|
||||
{ value: 'partnership', label: 'Partnership' },
|
||||
{ value: 'billing', label: 'Billing' },
|
||||
{ value: 'other', label: 'Other' },
|
||||
] as const
|
||||
|
||||
export const contactRequestSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, 'Name is required')
|
||||
.max(120, 'Name must be 120 characters or less')
|
||||
.regex(NO_EMAIL_HEADER_CONTROL_CHARS_REGEX, 'Invalid characters'),
|
||||
email: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, 'Email is required')
|
||||
.max(320)
|
||||
.transform((value) => value.toLowerCase())
|
||||
.refine((value) => quickValidateEmail(value).isValid, 'Enter a valid email'),
|
||||
company: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(120, 'Company must be 120 characters or less')
|
||||
.optional()
|
||||
.transform((value) => (value && value.length > 0 ? value : undefined)),
|
||||
topic: z.enum(CONTACT_TOPIC_VALUES, {
|
||||
errorMap: () => ({ message: 'Please select a topic' }),
|
||||
}),
|
||||
subject: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, 'Subject is required')
|
||||
.max(200, 'Subject must be 200 characters or less')
|
||||
.regex(NO_EMAIL_HEADER_CONTROL_CHARS_REGEX, 'Invalid characters'),
|
||||
message: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, 'Message is required')
|
||||
.max(5000, 'Message must be 5,000 characters or less'),
|
||||
})
|
||||
|
||||
export type ContactRequestPayload = z.infer<typeof contactRequestSchema>
|
||||
|
||||
export function getContactTopicLabel(value: ContactRequestPayload['topic']): string {
|
||||
return CONTACT_TOPIC_OPTIONS.find((option) => option.value === value)?.label ?? value
|
||||
}
|
||||
|
||||
export type HelpEmailType = 'bug' | 'feedback' | 'feature_request' | 'other'
|
||||
|
||||
export function mapContactTopicToHelpType(topic: ContactRequestPayload['topic']): HelpEmailType {
|
||||
switch (topic) {
|
||||
case 'feature_request':
|
||||
return 'feature_request'
|
||||
case 'support':
|
||||
return 'bug'
|
||||
case 'integration':
|
||||
return 'feedback'
|
||||
default:
|
||||
return 'other'
|
||||
}
|
||||
}
|
||||
354
apps/sim/app/(landing)/components/contact/contact-form.tsx
Normal file
@@ -0,0 +1,354 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import Link from 'next/link'
|
||||
import { Combobox, type ComboboxOption, Input, Textarea } from '@/components/emcn'
|
||||
import { Check } from '@/components/emcn/icons'
|
||||
import { getEnv } from '@/lib/core/config/env'
|
||||
import { captureClientEvent } from '@/lib/posthog/client'
|
||||
import {
|
||||
CONTACT_TOPIC_OPTIONS,
|
||||
type ContactRequestPayload,
|
||||
contactRequestSchema,
|
||||
} from '@/app/(landing)/components/contact/consts'
|
||||
import { LandingField } from '@/app/(landing)/components/forms/landing-field'
|
||||
|
||||
type ContactField = keyof ContactRequestPayload
|
||||
type ContactErrors = Partial<Record<ContactField, string>>
|
||||
|
||||
interface ContactFormState {
|
||||
name: string
|
||||
email: string
|
||||
company: string
|
||||
topic: ContactRequestPayload['topic'] | ''
|
||||
subject: string
|
||||
message: string
|
||||
}
|
||||
|
||||
const INITIAL_FORM_STATE: ContactFormState = {
|
||||
name: '',
|
||||
email: '',
|
||||
company: '',
|
||||
topic: '',
|
||||
subject: '',
|
||||
message: '',
|
||||
}
|
||||
|
||||
const LANDING_INPUT =
|
||||
'h-[40px] rounded-[5px] border border-[var(--landing-bg-elevated)] bg-[var(--landing-bg-surface)] px-3 font-[430] font-season text-[14px] text-[var(--landing-text)] outline-none transition-colors placeholder:text-[var(--landing-text-muted)] focus:border-[var(--landing-border-strong)]'
|
||||
|
||||
const LANDING_TEXTAREA =
|
||||
'min-h-[140px] rounded-[5px] border border-[var(--landing-bg-elevated)] bg-[var(--landing-bg-surface)] px-3 py-2.5 font-[430] font-season text-[14px] text-[var(--landing-text)] outline-none transition-colors placeholder:text-[var(--landing-text-muted)] focus:border-[var(--landing-border-strong)]'
|
||||
|
||||
const LANDING_COMBOBOX =
|
||||
'h-[40px] rounded-[5px] border border-[var(--landing-bg-elevated)] bg-[var(--landing-bg-surface)] px-3 font-[430] font-season text-[14px] text-[var(--landing-text)] hover:bg-[var(--landing-bg-surface)] focus-within:border-[var(--landing-border-strong)]'
|
||||
|
||||
const LANDING_SUBMIT =
|
||||
'flex h-[40px] w-full items-center justify-center rounded-[5px] border border-[var(--landing-text-subtle)] bg-[var(--landing-text-subtle)] font-[430] font-season text-[14px] text-[var(--landing-text-dark)] transition-colors hover:border-[var(--landing-bg-hover)] hover:bg-[var(--landing-bg-hover)] disabled:cursor-not-allowed disabled:opacity-60'
|
||||
|
||||
const LANDING_LABEL =
|
||||
'font-[500] font-season text-[13px] text-[var(--landing-text)] tracking-[0.02em]'
|
||||
|
||||
interface SubmitContactRequestInput extends ContactRequestPayload {
|
||||
website: string
|
||||
captchaToken?: string
|
||||
captchaUnavailable?: boolean
|
||||
}
|
||||
|
||||
async function submitContactRequest(payload: SubmitContactRequestInput) {
|
||||
const response = await fetch('/api/contact', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
const result = (await response.json().catch(() => null)) as {
|
||||
error?: string
|
||||
message?: string
|
||||
} | null
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result?.error || 'Failed to send message')
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function ContactForm() {
|
||||
const turnstileRef = useRef<TurnstileInstance>(null)
|
||||
|
||||
const contactMutation = useMutation({
|
||||
mutationFn: submitContactRequest,
|
||||
onSuccess: (_data, variables) => {
|
||||
captureClientEvent('landing_contact_submitted', { topic: variables.topic })
|
||||
setForm(INITIAL_FORM_STATE)
|
||||
setErrors({})
|
||||
setSubmitSuccess(true)
|
||||
},
|
||||
onError: () => {
|
||||
turnstileRef.current?.reset()
|
||||
},
|
||||
})
|
||||
|
||||
const [form, setForm] = useState<ContactFormState>(INITIAL_FORM_STATE)
|
||||
const [errors, setErrors] = useState<ContactErrors>({})
|
||||
const [submitSuccess, setSubmitSuccess] = useState(false)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [website, setWebsite] = useState('')
|
||||
const [widgetReady, setWidgetReady] = useState(false)
|
||||
const [turnstileSiteKey, setTurnstileSiteKey] = useState<string | undefined>()
|
||||
|
||||
useEffect(() => {
|
||||
setTurnstileSiteKey(getEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY'))
|
||||
}, [])
|
||||
|
||||
function updateField<TField extends keyof ContactFormState>(
|
||||
field: TField,
|
||||
value: ContactFormState[TField]
|
||||
) {
|
||||
setForm((prev) => ({ ...prev, [field]: value }))
|
||||
setErrors((prev) => {
|
||||
if (!prev[field as ContactField]) {
|
||||
return prev
|
||||
}
|
||||
const nextErrors = { ...prev }
|
||||
delete nextErrors[field as ContactField]
|
||||
return nextErrors
|
||||
})
|
||||
if (contactMutation.isError) {
|
||||
contactMutation.reset()
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
if (contactMutation.isPending || isSubmitting) return
|
||||
setIsSubmitting(true)
|
||||
|
||||
const parsed = contactRequestSchema.safeParse({
|
||||
...form,
|
||||
company: form.company || undefined,
|
||||
})
|
||||
|
||||
if (!parsed.success) {
|
||||
const fieldErrors = parsed.error.flatten().fieldErrors
|
||||
setErrors({
|
||||
name: fieldErrors.name?.[0],
|
||||
email: fieldErrors.email?.[0],
|
||||
company: fieldErrors.company?.[0],
|
||||
topic: fieldErrors.topic?.[0],
|
||||
subject: fieldErrors.subject?.[0],
|
||||
message: fieldErrors.message?.[0],
|
||||
})
|
||||
setIsSubmitting(false)
|
||||
return
|
||||
}
|
||||
|
||||
let captchaToken: string | undefined
|
||||
let captchaUnavailable: boolean | undefined
|
||||
const widget = turnstileRef.current
|
||||
|
||||
if (turnstileSiteKey) {
|
||||
if (widgetReady && widget) {
|
||||
try {
|
||||
widget.reset()
|
||||
widget.execute()
|
||||
captchaToken = await widget.getResponsePromise(30_000)
|
||||
} catch {
|
||||
captchaUnavailable = true
|
||||
}
|
||||
} else {
|
||||
captchaUnavailable = true
|
||||
}
|
||||
}
|
||||
|
||||
contactMutation.mutate({ ...parsed.data, website, captchaToken, captchaUnavailable })
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
|
||||
const isBusy = contactMutation.isPending || isSubmitting
|
||||
|
||||
const submitError = contactMutation.isError
|
||||
? toError(contactMutation.error).message || 'Failed to send message. Please try again.'
|
||||
: null
|
||||
|
||||
if (submitSuccess) {
|
||||
return (
|
||||
<div className='flex flex-col items-center px-8 py-16 text-center'>
|
||||
<div className='flex h-16 w-16 items-center justify-center rounded-full border border-[var(--landing-bg-elevated)] bg-[var(--landing-bg-surface)] text-[var(--landing-text)]'>
|
||||
<Check className='h-8 w-8' />
|
||||
</div>
|
||||
<h2 className='mt-6 font-[430] font-season text-[24px] text-[var(--landing-text)] leading-[1.2] tracking-[-0.02em]'>
|
||||
Message received
|
||||
</h2>
|
||||
<p className='mt-3 max-w-sm font-season text-[14px] text-[var(--landing-text-body)] leading-[1.6]'>
|
||||
Thanks for reaching out. We've sent a confirmation to your inbox and will get back to you
|
||||
shortly.
|
||||
</p>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setSubmitSuccess(false)}
|
||||
className='mt-6 font-season text-[13px] text-[var(--landing-text)] underline underline-offset-2 transition-opacity hover:opacity-80'
|
||||
>
|
||||
Send another message
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className='relative flex flex-col gap-5'>
|
||||
{/* Honeypot */}
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='pointer-events-none absolute left-[-9999px] h-px w-px overflow-hidden opacity-0'
|
||||
>
|
||||
<label htmlFor='contact-website'>Website</label>
|
||||
<input
|
||||
id='contact-website'
|
||||
name='website'
|
||||
type='text'
|
||||
tabIndex={-1}
|
||||
autoComplete='off'
|
||||
value={website}
|
||||
onChange={(event) => setWebsite(event.target.value)}
|
||||
data-lpignore='true'
|
||||
data-1p-ignore='true'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-5 sm:grid-cols-2'>
|
||||
<LandingField
|
||||
htmlFor='contact-name'
|
||||
label='Name'
|
||||
error={errors.name}
|
||||
labelClassName={LANDING_LABEL}
|
||||
>
|
||||
<Input
|
||||
id='contact-name'
|
||||
value={form.name}
|
||||
onChange={(event) => updateField('name', event.target.value)}
|
||||
placeholder='Your name'
|
||||
className={LANDING_INPUT}
|
||||
/>
|
||||
</LandingField>
|
||||
<LandingField
|
||||
htmlFor='contact-email'
|
||||
label='Email'
|
||||
error={errors.email}
|
||||
labelClassName={LANDING_LABEL}
|
||||
>
|
||||
<Input
|
||||
id='contact-email'
|
||||
type='email'
|
||||
value={form.email}
|
||||
onChange={(event) => updateField('email', event.target.value)}
|
||||
placeholder='you@company.com'
|
||||
className={LANDING_INPUT}
|
||||
/>
|
||||
</LandingField>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-5 sm:grid-cols-2'>
|
||||
<LandingField
|
||||
htmlFor='contact-company'
|
||||
label='Company'
|
||||
optional
|
||||
error={errors.company}
|
||||
labelClassName={LANDING_LABEL}
|
||||
>
|
||||
<Input
|
||||
id='contact-company'
|
||||
value={form.company}
|
||||
onChange={(event) => updateField('company', event.target.value)}
|
||||
placeholder='Company name'
|
||||
className={LANDING_INPUT}
|
||||
/>
|
||||
</LandingField>
|
||||
<LandingField
|
||||
htmlFor='contact-topic'
|
||||
label='Topic'
|
||||
error={errors.topic}
|
||||
labelClassName={LANDING_LABEL}
|
||||
>
|
||||
<Combobox
|
||||
options={CONTACT_TOPIC_OPTIONS as unknown as ComboboxOption[]}
|
||||
value={form.topic}
|
||||
selectedValue={form.topic}
|
||||
onChange={(value) => updateField('topic', value as ContactRequestPayload['topic'])}
|
||||
placeholder='Select a topic'
|
||||
editable={false}
|
||||
filterOptions={false}
|
||||
className={LANDING_COMBOBOX}
|
||||
/>
|
||||
</LandingField>
|
||||
</div>
|
||||
|
||||
<LandingField
|
||||
htmlFor='contact-subject'
|
||||
label='Subject'
|
||||
error={errors.subject}
|
||||
labelClassName={LANDING_LABEL}
|
||||
>
|
||||
<Input
|
||||
id='contact-subject'
|
||||
value={form.subject}
|
||||
onChange={(event) => updateField('subject', event.target.value)}
|
||||
placeholder='How can we help?'
|
||||
className={LANDING_INPUT}
|
||||
/>
|
||||
</LandingField>
|
||||
|
||||
<LandingField
|
||||
htmlFor='contact-message'
|
||||
label='Message'
|
||||
error={errors.message}
|
||||
labelClassName={LANDING_LABEL}
|
||||
>
|
||||
<Textarea
|
||||
id='contact-message'
|
||||
value={form.message}
|
||||
onChange={(event) => updateField('message', event.target.value)}
|
||||
placeholder='Share details so we can help as quickly as possible'
|
||||
className={LANDING_TEXTAREA}
|
||||
/>
|
||||
</LandingField>
|
||||
|
||||
{turnstileSiteKey ? (
|
||||
<Turnstile
|
||||
ref={turnstileRef}
|
||||
siteKey={turnstileSiteKey}
|
||||
options={{ execution: 'execute', appearance: 'execute', size: 'invisible' }}
|
||||
onWidgetLoad={() => setWidgetReady(true)}
|
||||
onExpire={() => setWidgetReady(false)}
|
||||
onError={() => setWidgetReady(false)}
|
||||
onUnsupported={() => setWidgetReady(false)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{submitError ? (
|
||||
<p role='alert' className='font-season text-[13px] text-[var(--text-error)]'>
|
||||
{submitError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<button type='submit' disabled={isBusy} className={LANDING_SUBMIT}>
|
||||
{isBusy ? 'Sending...' : 'Send message'}
|
||||
</button>
|
||||
|
||||
<p className='text-center font-season text-[12px] text-[var(--landing-text-muted)] leading-[1.6]'>
|
||||
By submitting, you agree to our{' '}
|
||||
<Link
|
||||
href='/privacy'
|
||||
className='text-[var(--landing-text)] underline underline-offset-2 transition-opacity hover:opacity-80'
|
||||
>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import {
|
||||
Combobox,
|
||||
Input,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
type DemoRequestPayload,
|
||||
demoRequestSchema,
|
||||
} from '@/app/(landing)/components/demo-request/consts'
|
||||
import { LandingField } from '@/app/(landing)/components/forms/landing-field'
|
||||
|
||||
interface DemoRequestModalProps {
|
||||
children: React.ReactNode
|
||||
@@ -49,136 +51,104 @@ const INITIAL_FORM_STATE: DemoRequestFormState = {
|
||||
details: '',
|
||||
}
|
||||
|
||||
interface LandingFieldProps {
|
||||
label: string
|
||||
htmlFor: string
|
||||
optional?: boolean
|
||||
error?: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
function LandingField({ label, htmlFor, optional, error, children }: LandingFieldProps) {
|
||||
return (
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
<label
|
||||
htmlFor={htmlFor}
|
||||
className='font-[430] font-season text-[13px] text-[var(--text-secondary)] tracking-[0.02em]'
|
||||
>
|
||||
{label}
|
||||
{optional ? <span className='ml-1 text-[var(--text-muted)]'>(optional)</span> : null}
|
||||
</label>
|
||||
{children}
|
||||
{error ? <p className='text-[12px] text-[var(--text-error)]'>{error}</p> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const LANDING_INPUT =
|
||||
'h-[32px] rounded-[5px] border border-[var(--border-1)] bg-[var(--surface-5)] px-2.5 font-[430] font-season text-[13.5px] text-[var(--text-primary)] transition-colors placeholder:text-[var(--text-muted)] outline-none'
|
||||
|
||||
async function submitDemoRequest(payload: DemoRequestPayload) {
|
||||
const response = await fetch('/api/demo-requests', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
const result = (await response.json().catch(() => null)) as {
|
||||
error?: string
|
||||
message?: string
|
||||
} | null
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result?.error || 'Failed to submit demo request')
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function DemoRequestModal({ children, theme = 'dark' }: DemoRequestModalProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [form, setForm] = useState<DemoRequestFormState>(INITIAL_FORM_STATE)
|
||||
const [errors, setErrors] = useState<DemoRequestErrors>({})
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
const [submitSuccess, setSubmitSuccess] = useState(false)
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
const demoMutation = useMutation({
|
||||
mutationFn: submitDemoRequest,
|
||||
onSuccess: (_data, variables) => {
|
||||
captureClientEvent('landing_demo_request_submitted', {
|
||||
company_size: variables.companySize,
|
||||
})
|
||||
setSubmitSuccess(true)
|
||||
},
|
||||
})
|
||||
|
||||
function resetForm() {
|
||||
setForm(INITIAL_FORM_STATE)
|
||||
setErrors({})
|
||||
setIsSubmitting(false)
|
||||
setSubmitError(null)
|
||||
setSubmitSuccess(false)
|
||||
}, [])
|
||||
demoMutation.reset()
|
||||
}
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(nextOpen: boolean) => {
|
||||
setOpen(nextOpen)
|
||||
resetForm()
|
||||
},
|
||||
[resetForm]
|
||||
)
|
||||
function handleOpenChange(nextOpen: boolean) {
|
||||
setOpen(nextOpen)
|
||||
resetForm()
|
||||
}
|
||||
|
||||
const updateField = useCallback(
|
||||
<TField extends keyof DemoRequestFormState>(
|
||||
field: TField,
|
||||
value: DemoRequestFormState[TField]
|
||||
) => {
|
||||
setForm((prev) => ({ ...prev, [field]: value }))
|
||||
setErrors((prev) => {
|
||||
if (!prev[field]) {
|
||||
return prev
|
||||
}
|
||||
|
||||
const nextErrors = { ...prev }
|
||||
delete nextErrors[field]
|
||||
return nextErrors
|
||||
})
|
||||
setSubmitError(null)
|
||||
setSubmitSuccess(false)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
setSubmitError(null)
|
||||
setSubmitSuccess(false)
|
||||
|
||||
const parsed = demoRequestSchema.safeParse({
|
||||
...form,
|
||||
phoneNumber: form.phoneNumber || undefined,
|
||||
})
|
||||
|
||||
if (!parsed.success) {
|
||||
const fieldErrors = parsed.error.flatten().fieldErrors
|
||||
setErrors({
|
||||
firstName: fieldErrors.firstName?.[0],
|
||||
lastName: fieldErrors.lastName?.[0],
|
||||
companyEmail: fieldErrors.companyEmail?.[0],
|
||||
phoneNumber: fieldErrors.phoneNumber?.[0],
|
||||
companySize: fieldErrors.companySize?.[0],
|
||||
details: fieldErrors.details?.[0],
|
||||
})
|
||||
return
|
||||
function updateField<TField extends keyof DemoRequestFormState>(
|
||||
field: TField,
|
||||
value: DemoRequestFormState[TField]
|
||||
) {
|
||||
setForm((prev) => ({ ...prev, [field]: value }))
|
||||
setErrors((prev) => {
|
||||
if (!prev[field]) {
|
||||
return prev
|
||||
}
|
||||
const nextErrors = { ...prev }
|
||||
delete nextErrors[field]
|
||||
return nextErrors
|
||||
})
|
||||
if (demoMutation.isError) {
|
||||
demoMutation.reset()
|
||||
}
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
if (demoMutation.isPending) return
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/demo-requests', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(parsed.data),
|
||||
})
|
||||
const parsed = demoRequestSchema.safeParse({
|
||||
...form,
|
||||
phoneNumber: form.phoneNumber || undefined,
|
||||
})
|
||||
|
||||
const result = (await response.json().catch(() => null)) as {
|
||||
error?: string
|
||||
message?: string
|
||||
} | null
|
||||
if (!parsed.success) {
|
||||
const fieldErrors = parsed.error.flatten().fieldErrors
|
||||
setErrors({
|
||||
firstName: fieldErrors.firstName?.[0],
|
||||
lastName: fieldErrors.lastName?.[0],
|
||||
companyEmail: fieldErrors.companyEmail?.[0],
|
||||
phoneNumber: fieldErrors.phoneNumber?.[0],
|
||||
companySize: fieldErrors.companySize?.[0],
|
||||
details: fieldErrors.details?.[0],
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result?.error || 'Failed to submit demo request')
|
||||
}
|
||||
demoMutation.mutate(parsed.data)
|
||||
}
|
||||
|
||||
setSubmitSuccess(true)
|
||||
captureClientEvent('landing_demo_request_submitted', {
|
||||
company_size: parsed.data.companySize,
|
||||
})
|
||||
} catch (error) {
|
||||
setSubmitError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to submit demo request. Please try again.'
|
||||
)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
},
|
||||
[form, resetForm]
|
||||
)
|
||||
const submitError = demoMutation.isError
|
||||
? demoMutation.error instanceof Error
|
||||
? demoMutation.error.message
|
||||
: 'Failed to submit demo request. Please try again.'
|
||||
: null
|
||||
|
||||
return (
|
||||
<Modal open={open} onOpenChange={handleOpenChange}>
|
||||
@@ -284,14 +254,16 @@ export function DemoRequestModal({ children, theme = 'dark' }: DemoRequestModalP
|
||||
|
||||
<ModalFooter className='flex-col items-stretch gap-3 border-t-0 bg-transparent pt-0'>
|
||||
{submitError && (
|
||||
<p className='font-season text-[13px] text-[var(--text-error)]'>{submitError}</p>
|
||||
<p role='alert' className='font-season text-[13px] text-[var(--text-error)]'>
|
||||
{submitError}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type='submit'
|
||||
disabled={isSubmitting}
|
||||
disabled={demoMutation.isPending}
|
||||
className='flex h-[32px] w-full items-center justify-center rounded-[5px] bg-[var(--text-primary)] font-[430] font-season text-[13.5px] text-[var(--bg)] transition-colors hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50'
|
||||
>
|
||||
{isSubmitting ? 'Submitting...' : 'Submit'}
|
||||
{demoMutation.isPending ? 'Submitting...' : 'Submit'}
|
||||
</button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
|
||||
@@ -31,6 +31,7 @@ const RESOURCES_LINKS: FooterItem[] = [
|
||||
{ label: 'Partners', href: '/partners' },
|
||||
{ label: 'Careers', href: 'https://jobs.ashbyhq.com/sim', external: true, externalArrow: true },
|
||||
{ label: 'Changelog', href: '/changelog' },
|
||||
{ label: 'Contact', href: '/contact' },
|
||||
]
|
||||
|
||||
const BLOCK_LINKS: FooterItem[] = [
|
||||
|
||||
49
apps/sim/app/(landing)/components/forms/landing-field.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { cloneElement, isValidElement } from 'react'
|
||||
|
||||
interface LandingFieldProps {
|
||||
label: string
|
||||
htmlFor: string
|
||||
optional?: boolean
|
||||
error?: string
|
||||
children: React.ReactNode
|
||||
/** Replaces the default label className. */
|
||||
labelClassName?: string
|
||||
}
|
||||
|
||||
const DEFAULT_LABEL_CLASSNAME =
|
||||
'font-[430] font-season text-[13px] text-[var(--text-secondary)] tracking-[0.02em]'
|
||||
|
||||
export function LandingField({
|
||||
label,
|
||||
htmlFor,
|
||||
optional,
|
||||
error,
|
||||
children,
|
||||
labelClassName,
|
||||
}: LandingFieldProps) {
|
||||
const errorId = error ? `${htmlFor}-error` : undefined
|
||||
const describedChild =
|
||||
errorId && isValidElement<{ 'aria-describedby'?: string; 'aria-invalid'?: boolean }>(children)
|
||||
? cloneElement(children, { 'aria-describedby': errorId, 'aria-invalid': true })
|
||||
: children
|
||||
return (
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
<div className='flex min-h-[18px] items-baseline justify-between gap-3'>
|
||||
<label htmlFor={htmlFor} className={labelClassName ?? DEFAULT_LABEL_CLASSNAME}>
|
||||
{label}
|
||||
{optional ? <span className='ml-1 text-[var(--text-muted)]'>(optional)</span> : null}
|
||||
</label>
|
||||
{error ? (
|
||||
<span
|
||||
id={errorId}
|
||||
role='alert'
|
||||
className='truncate font-season text-[12px] text-[var(--text-error)]'
|
||||
>
|
||||
{error}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{describedChild}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
40
apps/sim/app/(landing)/components/not-found-view.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import Link from 'next/link'
|
||||
import { getNavBlogPosts } from '@/lib/blog/registry'
|
||||
import AuthBackground from '@/app/(auth)/components/auth-background'
|
||||
import { AUTH_PRIMARY_CTA_BASE } from '@/app/(auth)/components/auth-button-classes'
|
||||
import Navbar from '@/app/(landing)/components/navbar/navbar'
|
||||
|
||||
/**
|
||||
* Shared 404 view used by every `not-found.tsx` under the landing surface.
|
||||
*
|
||||
* Rendered outside the route-group `(shell)` layout so it owns the full
|
||||
* viewport (Navbar + AuthBackground decoration, no Footer), matching the
|
||||
* root `/` 404 treatment.
|
||||
*/
|
||||
export default async function NotFoundView() {
|
||||
const blogPosts = await getNavBlogPosts()
|
||||
return (
|
||||
<AuthBackground className='dark font-[430] font-season'>
|
||||
<main className='relative flex min-h-full flex-col text-[var(--landing-text)]'>
|
||||
<header className='shrink-0 bg-[var(--landing-bg)]'>
|
||||
<Navbar blogPosts={blogPosts} />
|
||||
</header>
|
||||
<div className='relative z-30 flex flex-1 items-center justify-center px-4 pb-24'>
|
||||
<div className='flex flex-col items-center gap-3'>
|
||||
<h1 className='text-balance font-[430] font-season text-[40px] text-white leading-[110%] tracking-[-0.02em]'>
|
||||
Page not found
|
||||
</h1>
|
||||
<p className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_60%,transparent)] text-lg leading-[125%] tracking-[0.02em]'>
|
||||
The page you're looking for doesn't exist or has been moved.
|
||||
</p>
|
||||
<div className='mt-3 flex items-center gap-2'>
|
||||
<Link href='/' className={AUTH_PRIMARY_CTA_BASE}>
|
||||
Return to Home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</AuthBackground>
|
||||
)
|
||||
}
|
||||
@@ -39,6 +39,7 @@ const PRICING_TIERS: PricingTier[] = [
|
||||
'1,000 credits (trial)',
|
||||
'5GB file storage',
|
||||
'3 tables · 1,000 rows each',
|
||||
'1 personal workspace',
|
||||
'5 min execution limit',
|
||||
'7-day log retention',
|
||||
'CLI/SDK/MCP Access',
|
||||
@@ -56,6 +57,7 @@ const PRICING_TIERS: PricingTier[] = [
|
||||
'6,000 credits/mo · +50/day',
|
||||
'50GB file storage',
|
||||
'25 tables · 5,000 rows each',
|
||||
'Up to 3 personal workspaces',
|
||||
'50 min execution · 150 runs/min',
|
||||
'Unlimited log retention',
|
||||
'CLI/SDK/MCP Access',
|
||||
@@ -73,6 +75,7 @@ const PRICING_TIERS: PricingTier[] = [
|
||||
'25,000 credits/mo · +200/day',
|
||||
'500GB file storage',
|
||||
'25 tables · 5,000 rows each',
|
||||
'Up to 10 personal workspaces',
|
||||
'50 min execution · 300 runs/min',
|
||||
'Unlimited log retention',
|
||||
'CLI/SDK/MCP Access',
|
||||
@@ -89,6 +92,7 @@ const PRICING_TIERS: PricingTier[] = [
|
||||
'Custom credits & infra limits',
|
||||
'Custom file storage',
|
||||
'10,000 tables · 1M rows each',
|
||||
'Unlimited shared workspaces',
|
||||
'Custom execution limits',
|
||||
'Unlimited log retention',
|
||||
'SSO & SCIM · SOC2',
|
||||
@@ -264,10 +268,12 @@ export default function Pricing() {
|
||||
Pricing
|
||||
</h2>
|
||||
<p className='sr-only'>
|
||||
Sim pricing: Community plan is free with 1,000 credits and 5GB storage. Pro plan is $25
|
||||
per month with 6,000 credits and 50GB storage. Max plan is $100 per month with 25,000
|
||||
credits and 500GB storage. Enterprise pricing is custom with SSO, SCIM, SOC2 compliance,
|
||||
self-hosting, and dedicated support. All plans include CLI, SDK, and MCP access.
|
||||
Sim pricing: Community plan is free with 1,000 credits, 5GB storage, and 1 personal
|
||||
workspace. Pro plan is $25 per month with 6,000 credits, 50GB storage, and up to 3
|
||||
personal workspaces. Max plan is $100 per month with 25,000 credits, 500GB storage, and
|
||||
up to 10 personal workspaces. Enterprise pricing is custom with unlimited shared
|
||||
workspaces, SSO, SCIM, SOC2 compliance, self-hosting, and dedicated support. All plans
|
||||
include CLI, SDK, and MCP access.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
51
apps/sim/app/(landing)/contact/page.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { getNavBlogPosts } from '@/lib/blog/registry'
|
||||
import { isHosted } from '@/lib/core/config/feature-flags'
|
||||
import { SITE_URL } from '@/lib/core/utils/urls'
|
||||
import { ContactForm } from '@/app/(landing)/components/contact/contact-form'
|
||||
import Footer from '@/app/(landing)/components/footer/footer'
|
||||
import Navbar from '@/app/(landing)/components/navbar/navbar'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Contact Us',
|
||||
description:
|
||||
'Get in touch with Sim. Ask a general question, request an integration, or get help.',
|
||||
metadataBase: new URL(SITE_URL),
|
||||
alternates: { canonical: '/contact' },
|
||||
openGraph: {
|
||||
title: 'Contact Us | Sim',
|
||||
description: 'Get in touch with the Sim team for questions, integrations, and support.',
|
||||
type: 'website',
|
||||
},
|
||||
}
|
||||
|
||||
export default async function ContactPage() {
|
||||
const blogPosts = await getNavBlogPosts()
|
||||
|
||||
return (
|
||||
<main className='min-h-screen bg-[var(--landing-bg)] font-[430] font-season text-[var(--landing-text)]'>
|
||||
<header>
|
||||
<Navbar blogPosts={blogPosts} />
|
||||
</header>
|
||||
|
||||
<div className='mx-auto max-w-[640px] px-6 pt-[72px] pb-24 sm:px-12'>
|
||||
<span className='mb-4 block font-martian-mono text-[11px] text-[var(--landing-text-muted)] uppercase tracking-[0.12em]'>
|
||||
Contact us
|
||||
</span>
|
||||
<h1 className='mb-5 text-balance font-[500] text-4xl text-[var(--landing-text)] leading-[1.05] tracking-[-0.02em] md:text-5xl'>
|
||||
We're here to help
|
||||
</h1>
|
||||
<p className='text-pretty text-[var(--landing-text-muted)] text-base leading-[1.7]'>
|
||||
Got a general question, integration request, or need help? Send us a message and our team
|
||||
will get back to you.
|
||||
</p>
|
||||
|
||||
<div className='dark mt-14'>
|
||||
<ContactForm />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isHosted && <Footer hideCTA />}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Loader } from '@/components/emcn'
|
||||
|
||||
export default function IntegrationDetailLoading() {
|
||||
return (
|
||||
<div className='flex min-h-[60vh] items-center justify-center bg-[var(--landing-bg)]'>
|
||||
<Loader animate className='h-6 w-6 text-[var(--landing-text-muted)]' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,9 +3,9 @@ import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { SITE_URL } from '@/lib/core/utils/urls'
|
||||
import { IntegrationCtaButton } from '@/app/(landing)/integrations/[slug]/components/integration-cta-button'
|
||||
import { IntegrationFAQ } from '@/app/(landing)/integrations/[slug]/components/integration-faq'
|
||||
import { TemplateCardButton } from '@/app/(landing)/integrations/[slug]/components/template-card-button'
|
||||
import { IntegrationCtaButton } from '@/app/(landing)/integrations/(shell)/[slug]/components/integration-cta-button'
|
||||
import { IntegrationFAQ } from '@/app/(landing)/integrations/(shell)/[slug]/components/integration-faq'
|
||||
import { TemplateCardButton } from '@/app/(landing)/integrations/(shell)/[slug]/components/template-card-button'
|
||||
import { IntegrationIcon } from '@/app/(landing)/integrations/components/integration-icon'
|
||||
import { blockTypeToIconMap } from '@/app/(landing)/integrations/data/icon-mapping'
|
||||
import integrations from '@/app/(landing)/integrations/data/integrations.json'
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { Badge } from '@/components/emcn'
|
||||
import { SITE_URL } from '@/lib/core/utils/urls'
|
||||
import { IntegrationCard } from './components/integration-card'
|
||||
import { IntegrationGrid } from './components/integration-grid'
|
||||
import { RequestIntegrationModal } from './components/request-integration-modal'
|
||||
import { blockTypeToIconMap } from './data/icon-mapping'
|
||||
import integrations from './data/integrations.json'
|
||||
import { POPULAR_WORKFLOWS } from './data/popular-workflows'
|
||||
import type { Integration } from './data/types'
|
||||
import { IntegrationCard } from '@/app/(landing)/integrations/components/integration-card'
|
||||
import { IntegrationGrid } from '@/app/(landing)/integrations/components/integration-grid'
|
||||
import { RequestIntegrationModal } from '@/app/(landing)/integrations/components/request-integration-modal'
|
||||
import { blockTypeToIconMap } from '@/app/(landing)/integrations/data/icon-mapping'
|
||||
import integrations from '@/app/(landing)/integrations/data/integrations.json'
|
||||
import { POPULAR_WORKFLOWS } from '@/app/(landing)/integrations/data/popular-workflows'
|
||||
import type { Integration } from '@/app/(landing)/integrations/data/types'
|
||||
|
||||
const allIntegrations = integrations as Integration[]
|
||||
const INTEGRATION_COUNT = allIntegrations.length
|
||||
9
apps/sim/app/(landing)/integrations/[slug]/loading.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Loader } from '@/components/emcn'
|
||||
|
||||
export default function IntegrationDetailLoading() {
|
||||
return (
|
||||
<div className='flex min-h-[60vh] items-center justify-center bg-[var(--landing-bg)]'>
|
||||
<Loader animate className='h-6 w-6 text-[var(--landing-text-muted)]' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -91,6 +91,7 @@ import {
|
||||
HuggingFaceIcon,
|
||||
HunterIOIcon,
|
||||
IAMIcon,
|
||||
IdentityCenterIcon,
|
||||
ImageIcon,
|
||||
IncidentioIcon,
|
||||
InfisicalIcon,
|
||||
@@ -152,6 +153,7 @@ import {
|
||||
RootlyIcon,
|
||||
S3Icon,
|
||||
SalesforceIcon,
|
||||
SESIcon,
|
||||
SearchIcon,
|
||||
SecretsManagerIcon,
|
||||
SendgridIcon,
|
||||
@@ -284,6 +286,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
huggingface: HuggingFaceIcon,
|
||||
hunter: HunterIOIcon,
|
||||
iam: IAMIcon,
|
||||
identity_center: IdentityCenterIcon,
|
||||
image_generator: ImageIcon,
|
||||
imap: MailServerIcon,
|
||||
incidentio: IncidentioIcon,
|
||||
@@ -352,6 +355,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
sentry: SentryIcon,
|
||||
serper: SerperIcon,
|
||||
servicenow: ServiceNowIcon,
|
||||
ses: SESIcon,
|
||||
sftp: SftpIcon,
|
||||
sharepoint: MicrosoftSharepointIcon,
|
||||
shopify: ShopifyIcon,
|
||||
|
||||
@@ -484,7 +484,7 @@
|
||||
"type": "dynamodb",
|
||||
"slug": "amazon-dynamodb",
|
||||
"name": "Amazon DynamoDB",
|
||||
"description": "Connect to Amazon DynamoDB",
|
||||
"description": "Get, put, query, scan, update, and delete items in Amazon DynamoDB tables",
|
||||
"longDescription": "Integrate Amazon DynamoDB into workflows. Supports Get, Put, Query, Scan, Update, Delete, and Introspect operations on DynamoDB tables.",
|
||||
"bgColor": "linear-gradient(45deg, #2E27AD 0%, #527FFF 100%)",
|
||||
"iconName": "DynamoDBIcon",
|
||||
@@ -1464,9 +1464,21 @@
|
||||
{
|
||||
"name": "Remove User from Group",
|
||||
"description": "Remove an IAM user from a group"
|
||||
},
|
||||
{
|
||||
"name": "List Attached Role Policies",
|
||||
"description": "List all managed policies attached to an IAM role"
|
||||
},
|
||||
{
|
||||
"name": "List Attached User Policies",
|
||||
"description": "List all managed policies attached to an IAM user"
|
||||
},
|
||||
{
|
||||
"name": "Simulate Principal Policy",
|
||||
"description": "Simulate whether a user, role, or group is allowed to perform specific AWS actions — useful for pre-flight access checks"
|
||||
}
|
||||
],
|
||||
"operationCount": 18,
|
||||
"operationCount": 21,
|
||||
"triggers": [],
|
||||
"triggerCount": 0,
|
||||
"authType": "none",
|
||||
@@ -1474,6 +1486,73 @@
|
||||
"integrationTypes": ["developer-tools", "security"],
|
||||
"tags": ["cloud", "identity"]
|
||||
},
|
||||
{
|
||||
"type": "identity_center",
|
||||
"slug": "aws-identity-center",
|
||||
"name": "AWS Identity Center",
|
||||
"description": "Manage temporary elevated access in AWS IAM Identity Center",
|
||||
"longDescription": "Provision and revoke temporary access to AWS accounts via IAM Identity Center (SSO). Assign permission sets to users or groups, look up users by email, and list accounts and permission sets for access request workflows.",
|
||||
"bgColor": "linear-gradient(45deg, #BD0816 0%, #FF5252 100%)",
|
||||
"iconName": "IdentityCenterIcon",
|
||||
"docsUrl": "https://docs.sim.ai/tools/identity-center",
|
||||
"operations": [
|
||||
{
|
||||
"name": "List Instances",
|
||||
"description": "List all AWS IAM Identity Center instances in your account"
|
||||
},
|
||||
{
|
||||
"name": "List Accounts",
|
||||
"description": "List all AWS accounts in your organization"
|
||||
},
|
||||
{
|
||||
"name": "Describe Account",
|
||||
"description": "Retrieve details about a specific AWS account by its ID"
|
||||
},
|
||||
{
|
||||
"name": "List Permission Sets",
|
||||
"description": "List all permission sets defined in an IAM Identity Center instance"
|
||||
},
|
||||
{
|
||||
"name": "Get User",
|
||||
"description": "Look up a user in the Identity Store by email address"
|
||||
},
|
||||
{
|
||||
"name": "Get Group",
|
||||
"description": "Look up a group in the Identity Store by display name"
|
||||
},
|
||||
{
|
||||
"name": "List Groups",
|
||||
"description": "List all groups in the Identity Store"
|
||||
},
|
||||
{
|
||||
"name": "Create Account Assignment",
|
||||
"description": "Grant a user or group access to an AWS account via a permission set (temporary elevated access)"
|
||||
},
|
||||
{
|
||||
"name": "Delete Account Assignment",
|
||||
"description": "Revoke a user or group access to an AWS account by removing a permission set assignment"
|
||||
},
|
||||
{
|
||||
"name": "Check Assignment Status",
|
||||
"description": "Check the provisioning status of an account assignment creation request"
|
||||
},
|
||||
{
|
||||
"name": "Check Assignment Deletion Status",
|
||||
"description": "Check the deprovisioning status of an account assignment deletion request"
|
||||
},
|
||||
{
|
||||
"name": "List Account Assignments",
|
||||
"description": "List all account assignments for a specific user or group across all accounts"
|
||||
}
|
||||
],
|
||||
"operationCount": 12,
|
||||
"triggers": [],
|
||||
"triggerCount": 0,
|
||||
"authType": "none",
|
||||
"category": "tools",
|
||||
"integrationTypes": ["security", "developer-tools"],
|
||||
"tags": ["cloud", "identity"]
|
||||
},
|
||||
{
|
||||
"type": "secrets_manager",
|
||||
"slug": "aws-secrets-manager",
|
||||
@@ -1513,6 +1592,61 @@
|
||||
"integrationTypes": ["developer-tools", "security"],
|
||||
"tags": ["cloud", "secrets-management"]
|
||||
},
|
||||
{
|
||||
"type": "ses",
|
||||
"slug": "aws-ses",
|
||||
"name": "AWS SES",
|
||||
"description": "Send emails and manage templates with AWS Simple Email Service",
|
||||
"longDescription": "Integrate AWS SES v2 into the workflow. Send simple, templated, and bulk emails. Manage email templates and retrieve account sending quota and verified identity information.",
|
||||
"bgColor": "linear-gradient(45deg, #BD0816 0%, #FF5252 100%)",
|
||||
"iconName": "SESIcon",
|
||||
"docsUrl": "https://docs.sim.ai/tools/ses",
|
||||
"operations": [
|
||||
{
|
||||
"name": "Send Email",
|
||||
"description": "Send an email via AWS SES using simple or HTML content"
|
||||
},
|
||||
{
|
||||
"name": "Send Templated Email",
|
||||
"description": "Send an email using an SES email template with dynamic template data"
|
||||
},
|
||||
{
|
||||
"name": "Send Bulk Email",
|
||||
"description": "Send emails to multiple recipients using an SES template with per-recipient data"
|
||||
},
|
||||
{
|
||||
"name": "List Identities",
|
||||
"description": "List all verified email identities (email addresses and domains) in your SES account"
|
||||
},
|
||||
{
|
||||
"name": "Get Account",
|
||||
"description": "Get SES account sending quota and status information"
|
||||
},
|
||||
{
|
||||
"name": "Create Template",
|
||||
"description": "Create a new SES email template for use with templated email sending"
|
||||
},
|
||||
{
|
||||
"name": "Get Template",
|
||||
"description": "Retrieve the content and details of an SES email template"
|
||||
},
|
||||
{
|
||||
"name": "List Templates",
|
||||
"description": "List all SES email templates in your account"
|
||||
},
|
||||
{
|
||||
"name": "Delete Template",
|
||||
"description": "Delete an existing SES email template"
|
||||
}
|
||||
],
|
||||
"operationCount": 9,
|
||||
"triggers": [],
|
||||
"triggerCount": 0,
|
||||
"authType": "none",
|
||||
"category": "tools",
|
||||
"integrationTypes": ["email", "analytics", "developer-tools"],
|
||||
"tags": ["cloud", "marketing"]
|
||||
},
|
||||
{
|
||||
"type": "sts",
|
||||
"slug": "aws-sts",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { Metadata } from 'next'
|
||||
import Link from 'next/link'
|
||||
import { AUTH_PRIMARY_CTA_BASE } from '@/app/(auth)/components/auth-button-classes'
|
||||
import NotFoundView from '@/app/(landing)/components/not-found-view'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Page Not Found',
|
||||
@@ -8,21 +7,5 @@ export const metadata: Metadata = {
|
||||
}
|
||||
|
||||
export default function IntegrationsNotFound() {
|
||||
return (
|
||||
<div className='flex min-h-[60vh] items-center justify-center px-4 py-24'>
|
||||
<div className='flex flex-col items-center gap-3'>
|
||||
<h1 className='text-balance font-[430] font-season text-[40px] text-white leading-[110%] tracking-[-0.02em]'>
|
||||
Page not found
|
||||
</h1>
|
||||
<p className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_60%,transparent)] text-lg leading-[125%] tracking-[0.02em]'>
|
||||
The page you're looking for doesn't exist or has been moved.
|
||||
</p>
|
||||
<div className='mt-3 flex items-center gap-2'>
|
||||
<Link href='/' className={AUTH_PRIMARY_CTA_BASE}>
|
||||
Return to Home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
return <NotFoundView />
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Loader } from '@/components/emcn'
|
||||
|
||||
export default function ModelDetailLoading() {
|
||||
return (
|
||||
<div className='flex min-h-[60vh] items-center justify-center bg-[var(--landing-bg)]'>
|
||||
<Loader animate className='h-6 w-6 text-[var(--landing-text-muted)]' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Loader } from '@/components/emcn'
|
||||
|
||||
export default function ModelProviderLoading() {
|
||||
return (
|
||||
<div className='flex min-h-[60vh] items-center justify-center bg-[var(--landing-bg)]'>
|
||||
<Loader animate className='h-6 w-6 text-[var(--landing-text-muted)]' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Loader } from '@/components/emcn'
|
||||
|
||||
export default function ModelDetailLoading() {
|
||||
return (
|
||||
<div className='flex min-h-[60vh] items-center justify-center bg-[var(--landing-bg)]'>
|
||||
<Loader animate className='h-6 w-6 text-[var(--landing-text-muted)]' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
9
apps/sim/app/(landing)/models/[provider]/loading.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Loader } from '@/components/emcn'
|
||||
|
||||
export default function ModelProviderLoading() {
|
||||
return (
|
||||
<div className='flex min-h-[60vh] items-center justify-center bg-[var(--landing-bg)]'>
|
||||
<Loader animate className='h-6 w-6 text-[var(--landing-text-muted)]' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { Metadata } from 'next'
|
||||
import Link from 'next/link'
|
||||
import { AUTH_PRIMARY_CTA_BASE } from '@/app/(auth)/components/auth-button-classes'
|
||||
import NotFoundView from '@/app/(landing)/components/not-found-view'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Page Not Found',
|
||||
@@ -8,21 +7,5 @@ export const metadata: Metadata = {
|
||||
}
|
||||
|
||||
export default function ModelsNotFound() {
|
||||
return (
|
||||
<div className='flex min-h-[60vh] items-center justify-center px-4 py-24'>
|
||||
<div className='flex flex-col items-center gap-3'>
|
||||
<h1 className='text-balance font-[430] font-season text-[40px] text-white leading-[110%] tracking-[-0.02em]'>
|
||||
Page not found
|
||||
</h1>
|
||||
<p className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_60%,transparent)] text-lg leading-[125%] tracking-[0.02em]'>
|
||||
The page you're looking for doesn't exist or has been moved.
|
||||
</p>
|
||||
<div className='mt-3 flex items-center gap-2'>
|
||||
<Link href='/' className={AUTH_PRIMARY_CTA_BASE}>
|
||||
Return to Home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
return <NotFoundView />
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import type React from 'react'
|
||||
import { createContext, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { client } from '@/lib/auth/auth-client'
|
||||
import { extractSessionDataFromAuthClientResult } from '@/lib/auth/session-response'
|
||||
@@ -34,6 +35,8 @@ export type SessionHookResult = {
|
||||
|
||||
export const SessionContext = createContext<SessionHookResult | null>(null)
|
||||
|
||||
const logger = createLogger('SessionProvider')
|
||||
|
||||
export function SessionProvider({ children }: { children: React.ReactNode }) {
|
||||
const [data, setData] = useState<AppSession>(null)
|
||||
const [isPending, setIsPending] = useState(true)
|
||||
@@ -49,14 +52,18 @@ export function SessionProvider({ children }: { children: React.ReactNode }) {
|
||||
: await client.getSession()
|
||||
const session = extractSessionDataFromAuthClientResult(res) as AppSession
|
||||
setData(session)
|
||||
return session
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e : new Error('Failed to fetch session'))
|
||||
return null
|
||||
} finally {
|
||||
setIsPending(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let isCancelled = false
|
||||
|
||||
// Check if user was redirected after plan upgrade
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const wasUpgraded = params.get('upgraded') === 'true'
|
||||
@@ -69,12 +76,51 @@ export function SessionProvider({ children }: { children: React.ReactNode }) {
|
||||
window.history.replaceState({}, '', newUrl)
|
||||
}
|
||||
|
||||
loadSession(wasUpgraded).then(() => {
|
||||
if (wasUpgraded) {
|
||||
queryClient.invalidateQueries({ queryKey: ['organizations'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['subscription'] })
|
||||
const initializeSession = async () => {
|
||||
const session = await loadSession(wasUpgraded)
|
||||
|
||||
if (!wasUpgraded || isCancelled) {
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['organizations'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['subscription'] })
|
||||
|
||||
const activeOrganizationId = session?.session?.activeOrganizationId ?? null
|
||||
if (activeOrganizationId) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/organizations')
|
||||
if (!response.ok) {
|
||||
return
|
||||
}
|
||||
|
||||
const orgData = (await response.json()) as {
|
||||
organizations?: Array<{ id: string }>
|
||||
}
|
||||
const organizationId = orgData.organizations?.[0]?.id
|
||||
|
||||
if (!organizationId || isCancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
await client.organization.setActive({ organizationId })
|
||||
|
||||
if (!isCancelled) {
|
||||
await loadSession(true)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to activate organization after subscription upgrade', { error })
|
||||
}
|
||||
}
|
||||
|
||||
void initializeSession()
|
||||
|
||||
return () => {
|
||||
isCancelled = true
|
||||
}
|
||||
}, [loadSession, queryClient])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -107,9 +153,13 @@ export function SessionProvider({ children }: { children: React.ReactNode }) {
|
||||
.catch(() => {})
|
||||
}, [data, isPending])
|
||||
|
||||
const refetch = useCallback(async () => {
|
||||
await loadSession()
|
||||
}, [loadSession])
|
||||
|
||||
const value = useMemo<SessionHookResult>(
|
||||
() => ({ data, isPending, error, refetch: loadSession }),
|
||||
[data, isPending, error, loadSession]
|
||||
() => ({ data, isPending, error, refetch }),
|
||||
[data, isPending, error, refetch]
|
||||
)
|
||||
|
||||
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { generateAgentCard, generateSkillsFromWorkflow } from '@/lib/a2a/agent-c
|
||||
import type { AgentCapabilities, AgentSkill } from '@/lib/a2a/types'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { getRedisClient } from '@/lib/core/config/redis'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
|
||||
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
|
||||
@@ -22,321 +23,335 @@ interface RouteParams {
|
||||
/**
|
||||
* GET - Returns the Agent Card for discovery
|
||||
*/
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
|
||||
const { agentId } = await params
|
||||
export const GET = withRouteHandler(
|
||||
async (request: NextRequest, { params }: { params: Promise<RouteParams> }) => {
|
||||
const { agentId } = await params
|
||||
|
||||
try {
|
||||
const [agent] = await db
|
||||
.select({
|
||||
agent: a2aAgent,
|
||||
workflow: workflow,
|
||||
try {
|
||||
const [agent] = await db
|
||||
.select({
|
||||
agent: a2aAgent,
|
||||
workflow: workflow,
|
||||
})
|
||||
.from(a2aAgent)
|
||||
.innerJoin(workflow, and(eq(a2aAgent.workflowId, workflow.id), isNull(workflow.archivedAt)))
|
||||
.where(and(eq(a2aAgent.id, agentId), isNull(a2aAgent.archivedAt)))
|
||||
.limit(1)
|
||||
|
||||
if (!agent) {
|
||||
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (!agent.agent.isPublished) {
|
||||
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: 'Agent not published' }, { status: 404 })
|
||||
}
|
||||
|
||||
const workspaceAccess = await checkWorkspaceAccess(agent.agent.workspaceId, auth.userId)
|
||||
if (!workspaceAccess.exists || !workspaceAccess.hasAccess) {
|
||||
return NextResponse.json({ error: 'Agent not published' }, { status: 404 })
|
||||
}
|
||||
}
|
||||
|
||||
const agentCard = generateAgentCard(
|
||||
{
|
||||
id: agent.agent.id,
|
||||
name: agent.agent.name,
|
||||
description: agent.agent.description,
|
||||
version: agent.agent.version,
|
||||
capabilities: agent.agent.capabilities as AgentCapabilities,
|
||||
skills: agent.agent.skills as AgentSkill[],
|
||||
},
|
||||
{
|
||||
id: agent.workflow.id,
|
||||
name: agent.workflow.name,
|
||||
description: agent.workflow.description,
|
||||
}
|
||||
)
|
||||
|
||||
return NextResponse.json(agentCard, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': agent.agent.isPublished ? 'public, max-age=3600' : 'private, no-cache',
|
||||
},
|
||||
})
|
||||
.from(a2aAgent)
|
||||
.innerJoin(workflow, and(eq(a2aAgent.workflowId, workflow.id), isNull(workflow.archivedAt)))
|
||||
.where(and(eq(a2aAgent.id, agentId), isNull(a2aAgent.archivedAt)))
|
||||
.limit(1)
|
||||
|
||||
if (!agent) {
|
||||
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
|
||||
} catch (error) {
|
||||
logger.error('Error getting Agent Card:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
|
||||
if (!agent.agent.isPublished) {
|
||||
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: 'Agent not published' }, { status: 404 })
|
||||
}
|
||||
|
||||
const workspaceAccess = await checkWorkspaceAccess(agent.agent.workspaceId, auth.userId)
|
||||
if (!workspaceAccess.exists || !workspaceAccess.hasAccess) {
|
||||
return NextResponse.json({ error: 'Agent not published' }, { status: 404 })
|
||||
}
|
||||
}
|
||||
|
||||
const agentCard = generateAgentCard(
|
||||
{
|
||||
id: agent.agent.id,
|
||||
name: agent.agent.name,
|
||||
description: agent.agent.description,
|
||||
version: agent.agent.version,
|
||||
capabilities: agent.agent.capabilities as AgentCapabilities,
|
||||
skills: agent.agent.skills as AgentSkill[],
|
||||
},
|
||||
{
|
||||
id: agent.workflow.id,
|
||||
name: agent.workflow.name,
|
||||
description: agent.workflow.description,
|
||||
}
|
||||
)
|
||||
|
||||
return NextResponse.json(agentCard, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': agent.agent.isPublished ? 'public, max-age=3600' : 'private, no-cache',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error getting Agent Card:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* PUT - Update an agent
|
||||
*/
|
||||
export async function PUT(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
|
||||
const { agentId } = await params
|
||||
export const PUT = withRouteHandler(
|
||||
async (request: NextRequest, { params }: { params: Promise<RouteParams> }) => {
|
||||
const { agentId } = await params
|
||||
|
||||
try {
|
||||
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
try {
|
||||
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const [existingAgent] = await db
|
||||
.select()
|
||||
.from(a2aAgent)
|
||||
.where(and(eq(a2aAgent.id, agentId), isNull(a2aAgent.archivedAt)))
|
||||
.limit(1)
|
||||
|
||||
if (!existingAgent) {
|
||||
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 (
|
||||
body.skillTags !== undefined &&
|
||||
(!Array.isArray(body.skillTags) ||
|
||||
!body.skillTags.every((tag: unknown): tag is string => typeof tag === 'string'))
|
||||
) {
|
||||
return NextResponse.json({ error: 'skillTags must be an array of strings' }, { status: 400 })
|
||||
}
|
||||
|
||||
let skills = body.skills ?? existingAgent.skills
|
||||
if (body.skillTags !== undefined) {
|
||||
const agentName = body.name ?? existingAgent.name
|
||||
const agentDescription = body.description ?? existingAgent.description
|
||||
skills = generateSkillsFromWorkflow(agentName, agentDescription, body.skillTags)
|
||||
}
|
||||
|
||||
const [updatedAgent] = await db
|
||||
.update(a2aAgent)
|
||||
.set({
|
||||
name: body.name ?? existingAgent.name,
|
||||
description: body.description ?? existingAgent.description,
|
||||
version: body.version ?? existingAgent.version,
|
||||
capabilities: body.capabilities ?? existingAgent.capabilities,
|
||||
skills,
|
||||
authentication: body.authentication ?? existingAgent.authentication,
|
||||
isPublished: body.isPublished ?? existingAgent.isPublished,
|
||||
publishedAt:
|
||||
body.isPublished && !existingAgent.isPublished ? new Date() : existingAgent.publishedAt,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(a2aAgent.id, agentId))
|
||||
.returning()
|
||||
|
||||
logger.info(`Updated A2A agent: ${agentId}`)
|
||||
|
||||
return NextResponse.json({ success: true, agent: updatedAgent })
|
||||
} catch (error) {
|
||||
logger.error('Error updating agent:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE - Delete an agent
|
||||
*/
|
||||
export async function DELETE(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
|
||||
const { agentId } = await params
|
||||
|
||||
try {
|
||||
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const [existingAgent] = await db
|
||||
.select()
|
||||
.from(a2aAgent)
|
||||
.where(and(eq(a2aAgent.id, agentId), isNull(a2aAgent.archivedAt)))
|
||||
.limit(1)
|
||||
|
||||
if (!existingAgent) {
|
||||
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}`)
|
||||
|
||||
captureServerEvent(
|
||||
auth.userId,
|
||||
'a2a_agent_deleted',
|
||||
{
|
||||
agent_id: agentId,
|
||||
workflow_id: existingAgent.workflowId,
|
||||
workspace_id: existingAgent.workspaceId,
|
||||
},
|
||||
{ groups: { workspace: existingAgent.workspaceId } }
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
logger.error('Error deleting agent:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST - Publish/unpublish an agent
|
||||
*/
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
|
||||
const { agentId } = await params
|
||||
|
||||
try {
|
||||
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn('A2A agent publish auth failed:', { error: auth.error, hasUserId: !!auth.userId })
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const [existingAgent] = await db
|
||||
.select()
|
||||
.from(a2aAgent)
|
||||
.where(and(eq(a2aAgent.id, agentId), isNull(a2aAgent.archivedAt)))
|
||||
.limit(1)
|
||||
|
||||
if (!existingAgent) {
|
||||
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'
|
||||
|
||||
if (action === 'publish') {
|
||||
const [wf] = await db
|
||||
.select({ isDeployed: workflow.isDeployed })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, existingAgent.workflowId))
|
||||
const [existingAgent] = await db
|
||||
.select()
|
||||
.from(a2aAgent)
|
||||
.where(and(eq(a2aAgent.id, agentId), isNull(a2aAgent.archivedAt)))
|
||||
.limit(1)
|
||||
|
||||
if (!wf?.isDeployed) {
|
||||
if (!existingAgent) {
|
||||
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 (
|
||||
body.skillTags !== undefined &&
|
||||
(!Array.isArray(body.skillTags) ||
|
||||
!body.skillTags.every((tag: unknown): tag is string => typeof tag === 'string'))
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Workflow must be deployed before publishing agent' },
|
||||
{ error: 'skillTags must be an array of strings' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
await db
|
||||
let skills = body.skills ?? existingAgent.skills
|
||||
if (body.skillTags !== undefined) {
|
||||
const agentName = body.name ?? existingAgent.name
|
||||
const agentDescription = body.description ?? existingAgent.description
|
||||
skills = generateSkillsFromWorkflow(agentName, agentDescription, body.skillTags)
|
||||
}
|
||||
|
||||
const [updatedAgent] = await db
|
||||
.update(a2aAgent)
|
||||
.set({
|
||||
isPublished: true,
|
||||
publishedAt: new Date(),
|
||||
name: body.name ?? existingAgent.name,
|
||||
description: body.description ?? existingAgent.description,
|
||||
version: body.version ?? existingAgent.version,
|
||||
capabilities: body.capabilities ?? existingAgent.capabilities,
|
||||
skills,
|
||||
authentication: body.authentication ?? existingAgent.authentication,
|
||||
isPublished: body.isPublished ?? existingAgent.isPublished,
|
||||
publishedAt:
|
||||
body.isPublished && !existingAgent.isPublished ? new Date() : existingAgent.publishedAt,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(a2aAgent.id, agentId))
|
||||
.returning()
|
||||
|
||||
const redis = getRedisClient()
|
||||
if (redis) {
|
||||
try {
|
||||
await redis.del(`a2a:agent:${agentId}:card`)
|
||||
} catch (err) {
|
||||
logger.warn('Failed to invalidate agent card cache', { agentId, error: err })
|
||||
}
|
||||
}
|
||||
logger.info(`Updated A2A agent: ${agentId}`)
|
||||
|
||||
logger.info(`Published A2A agent: ${agentId}`)
|
||||
captureServerEvent(
|
||||
auth.userId,
|
||||
'a2a_agent_published',
|
||||
{
|
||||
agent_id: agentId,
|
||||
workflow_id: existingAgent.workflowId,
|
||||
workspace_id: existingAgent.workspaceId,
|
||||
},
|
||||
{ groups: { workspace: existingAgent.workspaceId } }
|
||||
)
|
||||
return NextResponse.json({ success: true, isPublished: true })
|
||||
return NextResponse.json({ success: true, agent: updatedAgent })
|
||||
} catch (error) {
|
||||
logger.error('Error updating agent:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (action === 'unpublish') {
|
||||
await db
|
||||
.update(a2aAgent)
|
||||
.set({
|
||||
isPublished: false,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(a2aAgent.id, agentId))
|
||||
/**
|
||||
* DELETE - Delete an agent
|
||||
*/
|
||||
export const DELETE = withRouteHandler(
|
||||
async (request: NextRequest, { params }: { params: Promise<RouteParams> }) => {
|
||||
const { agentId } = await params
|
||||
|
||||
const redis = getRedisClient()
|
||||
if (redis) {
|
||||
try {
|
||||
await redis.del(`a2a:agent:${agentId}:card`)
|
||||
} catch (err) {
|
||||
logger.warn('Failed to invalidate agent card cache', { agentId, error: err })
|
||||
}
|
||||
try {
|
||||
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
logger.info(`Unpublished A2A agent: ${agentId}`)
|
||||
captureServerEvent(
|
||||
auth.userId,
|
||||
'a2a_agent_unpublished',
|
||||
{
|
||||
agent_id: agentId,
|
||||
workflow_id: existingAgent.workflowId,
|
||||
workspace_id: existingAgent.workspaceId,
|
||||
},
|
||||
{ groups: { workspace: existingAgent.workspaceId } }
|
||||
)
|
||||
return NextResponse.json({ success: true, isPublished: false })
|
||||
}
|
||||
|
||||
if (action === 'refresh') {
|
||||
const workflowData = await loadWorkflowFromNormalizedTables(existingAgent.workflowId)
|
||||
if (!workflowData) {
|
||||
return NextResponse.json({ error: 'Failed to load workflow' }, { status: 500 })
|
||||
}
|
||||
|
||||
const [wf] = await db
|
||||
.select({ name: workflow.name, description: workflow.description })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, existingAgent.workflowId))
|
||||
const [existingAgent] = await db
|
||||
.select()
|
||||
.from(a2aAgent)
|
||||
.where(and(eq(a2aAgent.id, agentId), isNull(a2aAgent.archivedAt)))
|
||||
.limit(1)
|
||||
|
||||
const skills = generateSkillsFromWorkflow(wf?.name || existingAgent.name, wf?.description)
|
||||
if (!existingAgent) {
|
||||
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
await db
|
||||
.update(a2aAgent)
|
||||
.set({
|
||||
skills,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(a2aAgent.id, agentId))
|
||||
const workspaceAccess = await checkWorkspaceAccess(existingAgent.workspaceId, auth.userId)
|
||||
if (!workspaceAccess.canWrite) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
logger.info(`Refreshed skills for A2A agent: ${agentId}`)
|
||||
return NextResponse.json({ success: true, skills })
|
||||
await db.delete(a2aAgent).where(eq(a2aAgent.id, agentId))
|
||||
|
||||
logger.info(`Deleted A2A agent: ${agentId}`)
|
||||
|
||||
captureServerEvent(
|
||||
auth.userId,
|
||||
'a2a_agent_deleted',
|
||||
{
|
||||
agent_id: agentId,
|
||||
workflow_id: existingAgent.workflowId,
|
||||
workspace_id: existingAgent.workspaceId,
|
||||
},
|
||||
{ groups: { workspace: existingAgent.workspaceId } }
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
logger.error('Error deleting agent:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Invalid action' }, { status: 400 })
|
||||
} catch (error) {
|
||||
logger.error('Error with agent action:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* POST - Publish/unpublish an agent
|
||||
*/
|
||||
export const POST = withRouteHandler(
|
||||
async (request: NextRequest, { params }: { params: Promise<RouteParams> }) => {
|
||||
const { agentId } = await params
|
||||
|
||||
try {
|
||||
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn('A2A agent publish auth failed:', {
|
||||
error: auth.error,
|
||||
hasUserId: !!auth.userId,
|
||||
})
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const [existingAgent] = await db
|
||||
.select()
|
||||
.from(a2aAgent)
|
||||
.where(and(eq(a2aAgent.id, agentId), isNull(a2aAgent.archivedAt)))
|
||||
.limit(1)
|
||||
|
||||
if (!existingAgent) {
|
||||
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'
|
||||
|
||||
if (action === 'publish') {
|
||||
const [wf] = await db
|
||||
.select({ isDeployed: workflow.isDeployed })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, existingAgent.workflowId))
|
||||
.limit(1)
|
||||
|
||||
if (!wf?.isDeployed) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Workflow must be deployed before publishing agent' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
await db
|
||||
.update(a2aAgent)
|
||||
.set({
|
||||
isPublished: true,
|
||||
publishedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(a2aAgent.id, agentId))
|
||||
|
||||
const redis = getRedisClient()
|
||||
if (redis) {
|
||||
try {
|
||||
await redis.del(`a2a:agent:${agentId}:card`)
|
||||
} catch (err) {
|
||||
logger.warn('Failed to invalidate agent card cache', { agentId, error: err })
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Published A2A agent: ${agentId}`)
|
||||
captureServerEvent(
|
||||
auth.userId,
|
||||
'a2a_agent_published',
|
||||
{
|
||||
agent_id: agentId,
|
||||
workflow_id: existingAgent.workflowId,
|
||||
workspace_id: existingAgent.workspaceId,
|
||||
},
|
||||
{ groups: { workspace: existingAgent.workspaceId } }
|
||||
)
|
||||
return NextResponse.json({ success: true, isPublished: true })
|
||||
}
|
||||
|
||||
if (action === 'unpublish') {
|
||||
await db
|
||||
.update(a2aAgent)
|
||||
.set({
|
||||
isPublished: false,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(a2aAgent.id, agentId))
|
||||
|
||||
const redis = getRedisClient()
|
||||
if (redis) {
|
||||
try {
|
||||
await redis.del(`a2a:agent:${agentId}:card`)
|
||||
} catch (err) {
|
||||
logger.warn('Failed to invalidate agent card cache', { agentId, error: err })
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Unpublished A2A agent: ${agentId}`)
|
||||
captureServerEvent(
|
||||
auth.userId,
|
||||
'a2a_agent_unpublished',
|
||||
{
|
||||
agent_id: agentId,
|
||||
workflow_id: existingAgent.workflowId,
|
||||
workspace_id: existingAgent.workspaceId,
|
||||
},
|
||||
{ groups: { workspace: existingAgent.workspaceId } }
|
||||
)
|
||||
return NextResponse.json({ success: true, isPublished: false })
|
||||
}
|
||||
|
||||
if (action === 'refresh') {
|
||||
const workflowData = await loadWorkflowFromNormalizedTables(existingAgent.workflowId)
|
||||
if (!workflowData) {
|
||||
return NextResponse.json({ error: 'Failed to load workflow' }, { status: 500 })
|
||||
}
|
||||
|
||||
const [wf] = await db
|
||||
.select({ name: workflow.name, description: workflow.description })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, existingAgent.workflowId))
|
||||
.limit(1)
|
||||
|
||||
const skills = generateSkillsFromWorkflow(wf?.name || existingAgent.name, wf?.description)
|
||||
|
||||
await db
|
||||
.update(a2aAgent)
|
||||
.set({
|
||||
skills,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(a2aAgent.id, agentId))
|
||||
|
||||
logger.info(`Refreshed skills for A2A agent: ${agentId}`)
|
||||
return NextResponse.json({ success: true, skills })
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Invalid action' }, { status: 400 })
|
||||
} catch (error) {
|
||||
logger.error('Error with agent action:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -14,6 +14,7 @@ import { generateSkillsFromWorkflow } from '@/lib/a2a/agent-card'
|
||||
import { A2A_DEFAULT_CAPABILITIES } from '@/lib/a2a/constants'
|
||||
import { sanitizeAgentName } from '@/lib/a2a/utils'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
|
||||
import { hasValidStartBlockInState } from '@/lib/workflows/triggers/trigger-utils'
|
||||
@@ -26,7 +27,7 @@ export const dynamic = 'force-dynamic'
|
||||
/**
|
||||
* GET - List all A2A agents for a workspace
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
try {
|
||||
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
@@ -84,12 +85,12 @@ export async function GET(request: NextRequest) {
|
||||
logger.error('Error listing agents:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* POST - Create a new A2A agent from a workflow
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
try {
|
||||
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
@@ -217,4 +218,4 @@ export async function POST(request: NextRequest) {
|
||||
logger.error('Error creating agent:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -19,6 +19,7 @@ import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server'
|
||||
import { getClientIp } from '@/lib/core/utils/request'
|
||||
import { SSE_HEADERS } from '@/lib/core/utils/sse'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { markExecutionCancelled } from '@/lib/execution/cancellation'
|
||||
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
|
||||
import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils'
|
||||
@@ -71,298 +72,310 @@ function hasCallerAccessToTask(
|
||||
/**
|
||||
* GET - Returns the Agent Card (discovery document)
|
||||
*/
|
||||
export async function GET(_request: NextRequest, { params }: { params: Promise<RouteParams> }) {
|
||||
const { agentId } = await params
|
||||
export const GET = withRouteHandler(
|
||||
async (_request: NextRequest, { params }: { params: Promise<RouteParams> }) => {
|
||||
const { agentId } = await params
|
||||
|
||||
const redis = getRedisClient()
|
||||
const cacheKey = `a2a:agent:${agentId}:card`
|
||||
|
||||
if (redis) {
|
||||
try {
|
||||
const cached = await redis.get(cacheKey)
|
||||
if (cached) {
|
||||
return NextResponse.json(JSON.parse(cached), {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'private, max-age=60',
|
||||
'X-Cache': 'HIT',
|
||||
},
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('Redis cache read failed', { agentId, error: err })
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const [agent] = await db
|
||||
.select({
|
||||
id: a2aAgent.id,
|
||||
name: a2aAgent.name,
|
||||
description: a2aAgent.description,
|
||||
version: a2aAgent.version,
|
||||
capabilities: a2aAgent.capabilities,
|
||||
skills: a2aAgent.skills,
|
||||
authentication: a2aAgent.authentication,
|
||||
isPublished: a2aAgent.isPublished,
|
||||
})
|
||||
.from(a2aAgent)
|
||||
.where(and(eq(a2aAgent.id, agentId), isNull(a2aAgent.archivedAt)))
|
||||
.limit(1)
|
||||
|
||||
if (!agent) {
|
||||
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (!agent.isPublished) {
|
||||
return NextResponse.json({ error: 'Agent not published' }, { status: 404 })
|
||||
}
|
||||
|
||||
const baseUrl = getBaseUrl()
|
||||
const brandConfig = getBrandConfig()
|
||||
|
||||
const authConfig = agent.authentication as { schemes?: string[] } | undefined
|
||||
const schemes = authConfig?.schemes || []
|
||||
const isPublic = schemes.includes('none')
|
||||
|
||||
const agentCard = {
|
||||
protocolVersion: '0.3.0',
|
||||
name: agent.name,
|
||||
description: agent.description || '',
|
||||
url: `${baseUrl}/api/a2a/serve/${agent.id}`,
|
||||
version: agent.version,
|
||||
preferredTransport: 'JSONRPC',
|
||||
documentationUrl: `${baseUrl}/docs/a2a`,
|
||||
provider: {
|
||||
organization: brandConfig.name,
|
||||
url: baseUrl,
|
||||
},
|
||||
capabilities: agent.capabilities,
|
||||
skills: agent.skills || [],
|
||||
...(isPublic
|
||||
? {}
|
||||
: {
|
||||
securitySchemes: {
|
||||
apiKey: {
|
||||
type: 'apiKey' as const,
|
||||
name: 'X-API-Key',
|
||||
in: 'header' as const,
|
||||
description: 'API key authentication',
|
||||
},
|
||||
},
|
||||
security: [{ apiKey: [] }],
|
||||
}),
|
||||
defaultInputModes: ['text/plain', 'application/json'],
|
||||
defaultOutputModes: ['text/plain', 'application/json'],
|
||||
}
|
||||
const redis = getRedisClient()
|
||||
const cacheKey = `a2a:agent:${agentId}:card`
|
||||
|
||||
if (redis) {
|
||||
try {
|
||||
await redis.set(cacheKey, JSON.stringify(agentCard), 'EX', 60)
|
||||
const cached = await redis.get(cacheKey)
|
||||
if (cached) {
|
||||
return NextResponse.json(JSON.parse(cached), {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'private, max-age=60',
|
||||
'X-Cache': 'HIT',
|
||||
},
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('Redis cache write failed', { agentId, error: err })
|
||||
logger.warn('Redis cache read failed', { agentId, error: err })
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(agentCard, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'private, max-age=60',
|
||||
'X-Cache': 'MISS',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error getting Agent Card:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
try {
|
||||
const [agent] = await db
|
||||
.select({
|
||||
id: a2aAgent.id,
|
||||
name: a2aAgent.name,
|
||||
description: a2aAgent.description,
|
||||
version: a2aAgent.version,
|
||||
capabilities: a2aAgent.capabilities,
|
||||
skills: a2aAgent.skills,
|
||||
authentication: a2aAgent.authentication,
|
||||
isPublished: a2aAgent.isPublished,
|
||||
})
|
||||
.from(a2aAgent)
|
||||
.where(and(eq(a2aAgent.id, agentId), isNull(a2aAgent.archivedAt)))
|
||||
.limit(1)
|
||||
|
||||
if (!agent) {
|
||||
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (!agent.isPublished) {
|
||||
return NextResponse.json({ error: 'Agent not published' }, { status: 404 })
|
||||
}
|
||||
|
||||
const baseUrl = getBaseUrl()
|
||||
const brandConfig = getBrandConfig()
|
||||
|
||||
const authConfig = agent.authentication as { schemes?: string[] } | undefined
|
||||
const schemes = authConfig?.schemes || []
|
||||
const isPublic = schemes.includes('none')
|
||||
|
||||
const agentCard = {
|
||||
protocolVersion: '0.3.0',
|
||||
name: agent.name,
|
||||
description: agent.description || '',
|
||||
url: `${baseUrl}/api/a2a/serve/${agent.id}`,
|
||||
version: agent.version,
|
||||
preferredTransport: 'JSONRPC',
|
||||
documentationUrl: `${baseUrl}/docs/a2a`,
|
||||
provider: {
|
||||
organization: brandConfig.name,
|
||||
url: baseUrl,
|
||||
},
|
||||
capabilities: agent.capabilities,
|
||||
skills: agent.skills || [],
|
||||
...(isPublic
|
||||
? {}
|
||||
: {
|
||||
securitySchemes: {
|
||||
apiKey: {
|
||||
type: 'apiKey' as const,
|
||||
name: 'X-API-Key',
|
||||
in: 'header' as const,
|
||||
description: 'API key authentication',
|
||||
},
|
||||
},
|
||||
security: [{ apiKey: [] }],
|
||||
}),
|
||||
defaultInputModes: ['text/plain', 'application/json'],
|
||||
defaultOutputModes: ['text/plain', 'application/json'],
|
||||
}
|
||||
|
||||
if (redis) {
|
||||
try {
|
||||
await redis.set(cacheKey, JSON.stringify(agentCard), 'EX', 60)
|
||||
} catch (err) {
|
||||
logger.warn('Redis cache write failed', { agentId, error: err })
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(agentCard, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'private, max-age=60',
|
||||
'X-Cache': 'MISS',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error getting Agent Card:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* POST - Handle JSON-RPC requests
|
||||
*/
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
|
||||
const { agentId } = await params
|
||||
export const POST = withRouteHandler(
|
||||
async (request: NextRequest, { params }: { params: Promise<RouteParams> }) => {
|
||||
const { agentId } = await params
|
||||
|
||||
try {
|
||||
const [agent] = await db
|
||||
.select({
|
||||
id: a2aAgent.id,
|
||||
name: a2aAgent.name,
|
||||
workflowId: a2aAgent.workflowId,
|
||||
workspaceId: a2aAgent.workspaceId,
|
||||
isPublished: a2aAgent.isPublished,
|
||||
capabilities: a2aAgent.capabilities,
|
||||
authentication: a2aAgent.authentication,
|
||||
})
|
||||
.from(a2aAgent)
|
||||
.where(and(eq(a2aAgent.id, agentId), isNull(a2aAgent.archivedAt)))
|
||||
.limit(1)
|
||||
try {
|
||||
const [agent] = await db
|
||||
.select({
|
||||
id: a2aAgent.id,
|
||||
name: a2aAgent.name,
|
||||
workflowId: a2aAgent.workflowId,
|
||||
workspaceId: a2aAgent.workspaceId,
|
||||
isPublished: a2aAgent.isPublished,
|
||||
capabilities: a2aAgent.capabilities,
|
||||
authentication: a2aAgent.authentication,
|
||||
})
|
||||
.from(a2aAgent)
|
||||
.where(and(eq(a2aAgent.id, agentId), isNull(a2aAgent.archivedAt)))
|
||||
.limit(1)
|
||||
|
||||
if (!agent) {
|
||||
return NextResponse.json(
|
||||
createError(null, A2A_ERROR_CODES.AGENT_UNAVAILABLE, 'Agent not found'),
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!agent.isPublished) {
|
||||
return NextResponse.json(
|
||||
createError(null, A2A_ERROR_CODES.AGENT_UNAVAILABLE, 'Agent not published'),
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
const authSchemes = (agent.authentication as { schemes?: string[] })?.schemes || []
|
||||
const requiresAuth = !authSchemes.includes('none')
|
||||
let authenticatedUserId: string | null = null
|
||||
let authenticatedAuthType: AuthResult['authType']
|
||||
let authenticatedApiKeyType: AuthResult['apiKeyType']
|
||||
|
||||
if (requiresAuth) {
|
||||
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
if (!agent) {
|
||||
return NextResponse.json(
|
||||
createError(null, A2A_ERROR_CODES.AUTHENTICATION_REQUIRED, 'Unauthorized'),
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
authenticatedUserId = auth.userId
|
||||
authenticatedAuthType = auth.authType
|
||||
authenticatedApiKeyType = auth.apiKeyType
|
||||
|
||||
if (auth.apiKeyType === 'workspace' && auth.workspaceId !== agent.workspaceId) {
|
||||
return NextResponse.json(
|
||||
createError(null, A2A_ERROR_CODES.AUTHENTICATION_REQUIRED, 'Access denied'),
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const workspaceAccess = await checkWorkspaceAccess(agent.workspaceId, authenticatedUserId)
|
||||
if (!workspaceAccess.exists || !workspaceAccess.hasAccess) {
|
||||
return NextResponse.json(
|
||||
createError(null, A2A_ERROR_CODES.AUTHENTICATION_REQUIRED, 'Access denied'),
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const [wf] = await db
|
||||
.select({ isDeployed: workflow.isDeployed })
|
||||
.from(workflow)
|
||||
.where(and(eq(workflow.id, agent.workflowId), isNull(workflow.archivedAt)))
|
||||
.limit(1)
|
||||
|
||||
if (!wf?.isDeployed) {
|
||||
return NextResponse.json(
|
||||
createError(null, A2A_ERROR_CODES.AGENT_UNAVAILABLE, 'Workflow is not deployed'),
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
|
||||
if (!isJSONRPCRequest(body)) {
|
||||
return NextResponse.json(
|
||||
createError(null, A2A_ERROR_CODES.INVALID_REQUEST, 'Invalid JSON-RPC request'),
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { id, method, params: rpcParams } = body
|
||||
const requestApiKey = request.headers.get('X-API-Key')
|
||||
const apiKey = authenticatedAuthType === AuthType.API_KEY ? requestApiKey : null
|
||||
const isPersonalApiKeyCaller =
|
||||
authenticatedAuthType === AuthType.API_KEY && authenticatedApiKeyType === 'personal'
|
||||
const callerFingerprint = getCallerFingerprint(request, authenticatedUserId)
|
||||
const billedUserId = await getWorkspaceBilledAccountUserId(agent.workspaceId)
|
||||
if (!billedUserId) {
|
||||
logger.error('Unable to resolve workspace billed account for A2A execution', {
|
||||
agentId: agent.id,
|
||||
workspaceId: agent.workspaceId,
|
||||
})
|
||||
return NextResponse.json(
|
||||
createError(
|
||||
id,
|
||||
A2A_ERROR_CODES.INTERNAL_ERROR,
|
||||
'Unable to resolve billing account for this workspace'
|
||||
),
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
const executionUserId =
|
||||
isPersonalApiKeyCaller && authenticatedUserId ? authenticatedUserId : billedUserId
|
||||
|
||||
logger.info(`A2A request: ${method} for agent ${agentId}`)
|
||||
|
||||
switch (method) {
|
||||
case A2A_METHODS.MESSAGE_SEND:
|
||||
return handleMessageSend(
|
||||
id,
|
||||
agent,
|
||||
rpcParams as MessageSendParams,
|
||||
apiKey,
|
||||
executionUserId,
|
||||
callerFingerprint
|
||||
)
|
||||
|
||||
case A2A_METHODS.MESSAGE_STREAM:
|
||||
return handleMessageStream(
|
||||
request,
|
||||
id,
|
||||
agent,
|
||||
rpcParams as MessageSendParams,
|
||||
apiKey,
|
||||
executionUserId,
|
||||
callerFingerprint
|
||||
)
|
||||
|
||||
case A2A_METHODS.TASKS_GET:
|
||||
return handleTaskGet(id, agent.id, rpcParams as TaskIdParams, callerFingerprint)
|
||||
|
||||
case A2A_METHODS.TASKS_CANCEL:
|
||||
return handleTaskCancel(id, agent.id, rpcParams as TaskIdParams, callerFingerprint)
|
||||
|
||||
case A2A_METHODS.TASKS_RESUBSCRIBE:
|
||||
return handleTaskResubscribe(
|
||||
request,
|
||||
id,
|
||||
agent.id,
|
||||
rpcParams as TaskIdParams,
|
||||
callerFingerprint
|
||||
)
|
||||
|
||||
case A2A_METHODS.PUSH_NOTIFICATION_SET:
|
||||
return handlePushNotificationSet(
|
||||
id,
|
||||
agent.id,
|
||||
rpcParams as PushNotificationSetParams,
|
||||
callerFingerprint
|
||||
)
|
||||
|
||||
case A2A_METHODS.PUSH_NOTIFICATION_GET:
|
||||
return handlePushNotificationGet(id, agent.id, rpcParams as TaskIdParams, callerFingerprint)
|
||||
|
||||
case A2A_METHODS.PUSH_NOTIFICATION_DELETE:
|
||||
return handlePushNotificationDelete(
|
||||
id,
|
||||
agent.id,
|
||||
rpcParams as TaskIdParams,
|
||||
callerFingerprint
|
||||
)
|
||||
|
||||
default:
|
||||
return NextResponse.json(
|
||||
createError(id, A2A_ERROR_CODES.METHOD_NOT_FOUND, `Method not found: ${method}`),
|
||||
createError(null, A2A_ERROR_CODES.AGENT_UNAVAILABLE, 'Agent not found'),
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!agent.isPublished) {
|
||||
return NextResponse.json(
|
||||
createError(null, A2A_ERROR_CODES.AGENT_UNAVAILABLE, 'Agent not published'),
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
const authSchemes = (agent.authentication as { schemes?: string[] })?.schemes || []
|
||||
const requiresAuth = !authSchemes.includes('none')
|
||||
let authenticatedUserId: string | null = null
|
||||
let authenticatedAuthType: AuthResult['authType']
|
||||
let authenticatedApiKeyType: AuthResult['apiKeyType']
|
||||
|
||||
if (requiresAuth) {
|
||||
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json(
|
||||
createError(null, A2A_ERROR_CODES.AUTHENTICATION_REQUIRED, 'Unauthorized'),
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
authenticatedUserId = auth.userId
|
||||
authenticatedAuthType = auth.authType
|
||||
authenticatedApiKeyType = auth.apiKeyType
|
||||
|
||||
if (auth.apiKeyType === 'workspace' && auth.workspaceId !== agent.workspaceId) {
|
||||
return NextResponse.json(
|
||||
createError(null, A2A_ERROR_CODES.AUTHENTICATION_REQUIRED, 'Access denied'),
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const workspaceAccess = await checkWorkspaceAccess(agent.workspaceId, authenticatedUserId)
|
||||
if (!workspaceAccess.exists || !workspaceAccess.hasAccess) {
|
||||
return NextResponse.json(
|
||||
createError(null, A2A_ERROR_CODES.AUTHENTICATION_REQUIRED, 'Access denied'),
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const [wf] = await db
|
||||
.select({ isDeployed: workflow.isDeployed })
|
||||
.from(workflow)
|
||||
.where(and(eq(workflow.id, agent.workflowId), isNull(workflow.archivedAt)))
|
||||
.limit(1)
|
||||
|
||||
if (!wf?.isDeployed) {
|
||||
return NextResponse.json(
|
||||
createError(null, A2A_ERROR_CODES.AGENT_UNAVAILABLE, 'Workflow is not deployed'),
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
|
||||
if (!isJSONRPCRequest(body)) {
|
||||
return NextResponse.json(
|
||||
createError(null, A2A_ERROR_CODES.INVALID_REQUEST, 'Invalid JSON-RPC request'),
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { id, method, params: rpcParams } = body
|
||||
const requestApiKey = request.headers.get('X-API-Key')
|
||||
const apiKey = authenticatedAuthType === AuthType.API_KEY ? requestApiKey : null
|
||||
const isPersonalApiKeyCaller =
|
||||
authenticatedAuthType === AuthType.API_KEY && authenticatedApiKeyType === 'personal'
|
||||
const callerFingerprint = getCallerFingerprint(request, authenticatedUserId)
|
||||
const billedUserId = await getWorkspaceBilledAccountUserId(agent.workspaceId)
|
||||
if (!billedUserId) {
|
||||
logger.error('Unable to resolve workspace billed account for A2A execution', {
|
||||
agentId: agent.id,
|
||||
workspaceId: agent.workspaceId,
|
||||
})
|
||||
return NextResponse.json(
|
||||
createError(
|
||||
id,
|
||||
A2A_ERROR_CODES.INTERNAL_ERROR,
|
||||
'Unable to resolve billing account for this workspace'
|
||||
),
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
const executionUserId =
|
||||
isPersonalApiKeyCaller && authenticatedUserId ? authenticatedUserId : billedUserId
|
||||
|
||||
logger.info(`A2A request: ${method} for agent ${agentId}`)
|
||||
|
||||
switch (method) {
|
||||
case A2A_METHODS.MESSAGE_SEND:
|
||||
return handleMessageSend(
|
||||
id,
|
||||
agent,
|
||||
rpcParams as MessageSendParams,
|
||||
apiKey,
|
||||
executionUserId,
|
||||
callerFingerprint
|
||||
)
|
||||
|
||||
case A2A_METHODS.MESSAGE_STREAM:
|
||||
return handleMessageStream(
|
||||
request,
|
||||
id,
|
||||
agent,
|
||||
rpcParams as MessageSendParams,
|
||||
apiKey,
|
||||
executionUserId,
|
||||
callerFingerprint
|
||||
)
|
||||
|
||||
case A2A_METHODS.TASKS_GET:
|
||||
return handleTaskGet(id, agent.id, rpcParams as TaskIdParams, callerFingerprint)
|
||||
|
||||
case A2A_METHODS.TASKS_CANCEL:
|
||||
return handleTaskCancel(id, agent.id, rpcParams as TaskIdParams, callerFingerprint)
|
||||
|
||||
case A2A_METHODS.TASKS_RESUBSCRIBE:
|
||||
return handleTaskResubscribe(
|
||||
request,
|
||||
id,
|
||||
agent.id,
|
||||
rpcParams as TaskIdParams,
|
||||
callerFingerprint
|
||||
)
|
||||
|
||||
case A2A_METHODS.PUSH_NOTIFICATION_SET:
|
||||
return handlePushNotificationSet(
|
||||
id,
|
||||
agent.id,
|
||||
rpcParams as PushNotificationSetParams,
|
||||
callerFingerprint
|
||||
)
|
||||
|
||||
case A2A_METHODS.PUSH_NOTIFICATION_GET:
|
||||
return handlePushNotificationGet(
|
||||
id,
|
||||
agent.id,
|
||||
rpcParams as TaskIdParams,
|
||||
callerFingerprint
|
||||
)
|
||||
|
||||
case A2A_METHODS.PUSH_NOTIFICATION_DELETE:
|
||||
return handlePushNotificationDelete(
|
||||
id,
|
||||
agent.id,
|
||||
rpcParams as TaskIdParams,
|
||||
callerFingerprint
|
||||
)
|
||||
|
||||
default:
|
||||
return NextResponse.json(
|
||||
createError(id, A2A_ERROR_CODES.METHOD_NOT_FOUND, `Method not found: ${method}`),
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error handling A2A request:', error)
|
||||
return NextResponse.json(
|
||||
createError(null, A2A_ERROR_CODES.INTERNAL_ERROR, 'Internal error'),
|
||||
{
|
||||
status: 500,
|
||||
}
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error handling A2A request:', error)
|
||||
return NextResponse.json(createError(null, A2A_ERROR_CODES.INTERNAL_ERROR, 'Internal error'), {
|
||||
status: 500,
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
async function getTaskForAgent(taskId: string, agentId: string, callerFingerprint?: string) {
|
||||
const [task] = await db.select().from(a2aTask).where(eq(a2aTask.id, taskId)).limit(1)
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { CertificateMetadata } from '@/lib/academy/types'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import type { TokenBucketConfig } from '@/lib/core/rate-limiter'
|
||||
import { RateLimiter } from '@/lib/core/rate-limiter'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
|
||||
const logger = createLogger('AcademyCertificatesAPI')
|
||||
|
||||
@@ -31,7 +32,7 @@ const IssueCertificateSchema = z.object({
|
||||
* Completion is client-attested: the client sends completed lesson IDs and the server
|
||||
* validates them against the full lesson list for the course.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
export const POST = withRouteHandler(async (req: NextRequest) => {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
@@ -150,7 +151,7 @@ export async function POST(req: NextRequest) {
|
||||
logger.error('Failed to issue certificate', { error })
|
||||
return NextResponse.json({ error: 'Failed to issue certificate' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* GET /api/academy/certificates?certificateNumber=SIM-2026-00042
|
||||
@@ -159,7 +160,7 @@ export async function POST(req: NextRequest) {
|
||||
* GET /api/academy/certificates?courseId=...
|
||||
* Authenticated endpoint for looking up the current user's certificate for a course.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
export const GET = withRouteHandler(async (req: NextRequest) => {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url)
|
||||
const certificateNumber = searchParams.get('certificateNumber')
|
||||
@@ -206,7 +207,7 @@ export async function GET(req: NextRequest) {
|
||||
logger.error('Failed to verify certificate', { error })
|
||||
return NextResponse.json({ error: 'Failed to verify certificate' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/** Generates a human-readable certificate number, e.g. SIM-2026-A3K9XZ2P */
|
||||
function generateCertificateNumber(): string {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
|
||||
const ENV_URLS: Record<string, string | undefined> = {
|
||||
dev: env.MOTHERSHIP_DEV_URL,
|
||||
@@ -38,7 +39,7 @@ async function isAdminRequestAuthorized() {
|
||||
* The request body (for POST) is forwarded as-is. Additional query params
|
||||
* (e.g. requestId for GET /traces) are forwarded.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
export const POST = withRouteHandler(async (req: NextRequest) => {
|
||||
if (!(await isAdminRequestAuthorized())) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
@@ -87,9 +88,9 @@ export async function POST(req: NextRequest) {
|
||||
{ status: 502 }
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
export const GET = withRouteHandler(async (req: NextRequest) => {
|
||||
if (!(await isAdminRequestAuthorized())) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
@@ -141,4 +142,4 @@ export async function GET(req: NextRequest) {
|
||||
{ status: 502 }
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { validateEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth'
|
||||
import { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format'
|
||||
import {
|
||||
@@ -13,7 +14,7 @@ const logger = createLogger('AuditLogsAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function GET(request: Request) {
|
||||
export const GET = withRouteHandler(async (request: Request) => {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
@@ -68,4 +69,4 @@ export async function GET(request: Request) {
|
||||
logger.error('Audit logs fetch error', { error: message })
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -39,7 +39,7 @@ vi.mock('@/lib/core/config/feature-flags', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
import { GET } from '@/app/api/auth/[...all]/route'
|
||||
import { GET, POST } from '@/app/api/auth/[...all]/route'
|
||||
|
||||
describe('auth catch-all route (DISABLE_AUTH get-session)', () => {
|
||||
beforeEach(() => {
|
||||
@@ -95,3 +95,49 @@ describe('auth catch-all route (DISABLE_AUTH get-session)', () => {
|
||||
expect(json).toEqual({ data: { ok: true } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('auth catch-all route organization mutations', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('blocks Better Auth organization mutation endpoints that bypass app lifecycle rules', async () => {
|
||||
const req = createMockRequest(
|
||||
'POST',
|
||||
undefined,
|
||||
{},
|
||||
'http://localhost:3000/api/auth/organization/create'
|
||||
)
|
||||
|
||||
const res = await POST(req as any)
|
||||
const json = await res.json()
|
||||
|
||||
expect(res.status).toBe(404)
|
||||
expect(handlerMocks.betterAuthPOST).not.toHaveBeenCalled()
|
||||
expect(json).toEqual({
|
||||
error: 'Organization mutations are handled by application API routes.',
|
||||
})
|
||||
})
|
||||
|
||||
it('allows safe Better Auth organization session endpoints', async () => {
|
||||
const { NextResponse } = await import('next/server')
|
||||
handlerMocks.betterAuthPOST.mockResolvedValueOnce(
|
||||
new NextResponse(JSON.stringify({ data: { ok: true } }), {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}) as any
|
||||
)
|
||||
|
||||
const req = createMockRequest(
|
||||
'POST',
|
||||
undefined,
|
||||
{},
|
||||
'http://localhost:3000/api/auth/organization/set-active'
|
||||
)
|
||||
|
||||
const res = await POST(req as any)
|
||||
const json = await res.json()
|
||||
|
||||
expect(handlerMocks.betterAuthPOST).toHaveBeenCalledTimes(1)
|
||||
expect(json).toEqual({ data: { ok: true } })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,12 +3,18 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { createAnonymousGetSessionResponse, ensureAnonymousUserExists } from '@/lib/auth/anonymous'
|
||||
import { isAuthDisabled } from '@/lib/core/config/feature-flags'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const { GET: betterAuthGET, POST: betterAuthPOST } = toNextJsHandler(auth.handler)
|
||||
const SAFE_ORGANIZATION_POST_PATHS = new Set(['organization/check-slug', 'organization/set-active'])
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
function isBlockedOrganizationMutationPath(path: string): boolean {
|
||||
return path.startsWith('organization/') && !SAFE_ORGANIZATION_POST_PATHS.has(path)
|
||||
}
|
||||
|
||||
export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
const url = new URL(request.url)
|
||||
const path = url.pathname.replace('/api/auth/', '')
|
||||
|
||||
@@ -18,6 +24,18 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
return betterAuthGET(request)
|
||||
}
|
||||
})
|
||||
|
||||
export const POST = betterAuthPOST
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const url = new URL(request.url)
|
||||
const path = url.pathname.replace('/api/auth/', '')
|
||||
|
||||
if (isBlockedOrganizationMutationPath(path)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Organization mutations are handled by application API routes.' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
return betterAuthPOST(request)
|
||||
})
|
||||
|
||||
@@ -4,10 +4,11 @@ import { createLogger } from '@sim/logger'
|
||||
import { and, desc, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
|
||||
const logger = createLogger('AuthAccountsAPI')
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
@@ -54,4 +55,4 @@ export async function GET(request: NextRequest) {
|
||||
logger.error('Failed to fetch accounts', { error })
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -34,6 +34,8 @@ vi.mock('@/lib/auth', () => ({
|
||||
}))
|
||||
vi.mock('@sim/logger', () => ({
|
||||
createLogger: vi.fn().mockReturnValue(mockLogger),
|
||||
runWithRequestContext: <T>(_ctx: unknown, fn: () => T): T => fn(),
|
||||
getRequestContext: () => undefined,
|
||||
}))
|
||||
|
||||
import { POST } from '@/app/api/auth/forget-password/route'
|
||||
|
||||
@@ -7,6 +7,7 @@ import { z } from 'zod'
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { isSameOrigin } from '@/lib/core/utils/validation'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -29,7 +30,7 @@ const forgetPasswordSchema = z.object({
|
||||
),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
try {
|
||||
const body = await request.json()
|
||||
|
||||
@@ -89,4 +90,4 @@ export async function POST(request: NextRequest) {
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import { jwtDecode } from 'jwt-decode'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import type { OAuthProvider } from '@/lib/oauth'
|
||||
import { parseProvider } from '@/lib/oauth'
|
||||
|
||||
@@ -19,7 +20,7 @@ interface GoogleIdToken {
|
||||
/**
|
||||
* Get all OAuth connections for the current user
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
@@ -134,4 +135,4 @@ export async function GET(request: NextRequest) {
|
||||
logger.error(`[${requestId}] Error fetching OAuth connections`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth'
|
||||
import {
|
||||
getCanonicalScopesForProvider,
|
||||
@@ -66,7 +67,7 @@ function toCredentialResponse(
|
||||
/**
|
||||
* Get credentials for a specific provider
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
@@ -340,4 +341,4 @@ export async function GET(request: NextRequest) {
|
||||
logger.error(`[${requestId}] Error fetching OAuth credentials`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -7,6 +7,7 @@ import { z } from 'zod'
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
@@ -22,7 +23,7 @@ const disconnectSchema = z.object({
|
||||
/**
|
||||
* Disconnect an OAuth provider for the current user
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
@@ -144,4 +145,4 @@ export async function POST(request: NextRequest) {
|
||||
logger.error(`[${requestId}] Error disconnecting OAuth provider`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -3,13 +3,14 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { getCredential, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('MicrosoftFileAPI')
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateRequestId()
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
@@ -110,4 +111,4 @@ export async function GET(request: NextRequest) {
|
||||
logger.error(`[${requestId}] Error fetching file from Microsoft OneDrive`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { validatePathSegment } from '@/lib/core/security/input-validation'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { getCredential, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import { GRAPH_ID_PATTERN } from '@/tools/microsoft_excel/utils'
|
||||
|
||||
@@ -13,7 +14,7 @@ const logger = createLogger('MicrosoftFilesAPI')
|
||||
/**
|
||||
* Get Excel files from Microsoft OneDrive
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
@@ -147,4 +148,4 @@ export async function GET(request: NextRequest) {
|
||||
logger.error(`[${requestId}] Error fetching Excel files from Microsoft OneDrive`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import { z } from 'zod'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import {
|
||||
getCredential,
|
||||
getOAuthToken,
|
||||
@@ -46,7 +47,7 @@ const tokenQuerySchema = z.object({
|
||||
* Supports both session-based authentication (for client-side requests)
|
||||
* and workflow-based authentication (for server-side requests)
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
logger.info(`[${requestId}] OAuth token API POST request received`)
|
||||
@@ -204,12 +205,12 @@ export async function POST(request: NextRequest) {
|
||||
logger.error(`[${requestId}] Error getting access token`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Get the access token for a specific credential
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
@@ -293,4 +294,4 @@ export async function GET(request: NextRequest) {
|
||||
logger.error(`[${requestId}] Error fetching access token`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { validateEnum, validatePathSegment } from '@/lib/core/security/input-validation'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
@@ -15,7 +16,7 @@ const logger = createLogger('WealthboxItemAPI')
|
||||
/**
|
||||
* Get a single item (note, contact, task) from Wealthbox
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
@@ -170,4 +171,4 @@ export async function GET(request: NextRequest) {
|
||||
logger.error(`[${requestId}] Error fetching Wealthbox item`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
@@ -14,7 +15,7 @@ const logger = createLogger('WealthboxItemsAPI')
|
||||
/**
|
||||
* Get items (notes, contacts, tasks) from Wealthbox
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
@@ -180,4 +181,4 @@ export async function GET(request: NextRequest) {
|
||||
logger.error(`[${requestId}] Error fetching Wealthbox items`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -4,13 +4,14 @@ import { and, eq, gt } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
|
||||
/**
|
||||
* Returns the original OAuth authorize parameters stored in the verification record
|
||||
* for a given consent code. Used by the consent page to reconstruct the authorize URL
|
||||
* when switching accounts.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
const session = await getSession()
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
@@ -56,4 +57,4 @@ export async function GET(request: NextRequest) {
|
||||
nonce: data.nonce,
|
||||
response_type: 'code',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
|
||||
const logger = createLogger('ShopifyCallback')
|
||||
|
||||
@@ -42,7 +43,7 @@ function validateHmac(searchParams: URLSearchParams, clientSecret: string): bool
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
const baseUrl = getBaseUrl()
|
||||
|
||||
try {
|
||||
@@ -164,4 +165,4 @@ export async function GET(request: NextRequest) {
|
||||
logger.error('Error in Shopify OAuth callback:', error)
|
||||
return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_callback_error`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { isSameOrigin } from '@/lib/core/utils/validation'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { processCredentialDraft } from '@/lib/credentials/draft-processor'
|
||||
import { safeAccountInsert } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
@@ -13,7 +14,7 @@ const logger = createLogger('ShopifyStore')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
const baseUrl = getBaseUrl()
|
||||
|
||||
try {
|
||||
@@ -129,4 +130,4 @@ export async function GET(request: NextRequest) {
|
||||
logger.error('Error storing Shopify token:', error)
|
||||
return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_store_error`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { isRegistrationDisabled } from '@/lib/core/config/feature-flags'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { getOAuthProviderStatus } from '@/app/(auth)/components/oauth-provider-checker'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function GET() {
|
||||
export const GET = withRouteHandler(async () => {
|
||||
const { githubAvailable, googleAvailable } = await getOAuthProviderStatus()
|
||||
return NextResponse.json({
|
||||
githubAvailable,
|
||||
googleAvailable,
|
||||
registrationDisabled: isRegistrationDisabled,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -31,6 +31,8 @@ vi.mock('@/lib/auth', () => ({
|
||||
}))
|
||||
vi.mock('@sim/logger', () => ({
|
||||
createLogger: vi.fn().mockReturnValue(mockLogger),
|
||||
runWithRequestContext: <T>(_ctx: unknown, fn: () => T): T => fn(),
|
||||
getRequestContext: () => undefined,
|
||||
}))
|
||||
|
||||
import { POST } from '@/app/api/auth/reset-password/route'
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -19,7 +20,7 @@ const resetPasswordSchema = z.object({
|
||||
.regex(/[^A-Za-z0-9]/, 'Password must contain at least one special character'),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
try {
|
||||
const body = await request.json()
|
||||
|
||||
@@ -58,4 +59,4 @@ export async function POST(request: NextRequest) {
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getSession } from '@/lib/auth'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { isSameOrigin } from '@/lib/core/utils/validation'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { getScopesForService } from '@/lib/oauth/utils'
|
||||
|
||||
const logger = createLogger('ShopifyAuthorize')
|
||||
@@ -13,7 +14,7 @@ export const dynamic = 'force-dynamic'
|
||||
|
||||
const SHOPIFY_SCOPES = getScopesForService('shopify').join(',')
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
@@ -208,4 +209,4 @@ export async function GET(request: NextRequest) {
|
||||
logger.error('Error initiating Shopify authorization:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -4,10 +4,11 @@ import { headers } from 'next/headers'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { isAuthDisabled } from '@/lib/core/config/feature-flags'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
|
||||
const logger = createLogger('SocketTokenAPI')
|
||||
|
||||
export async function POST() {
|
||||
export const POST = withRouteHandler(async () => {
|
||||
if (isAuthDisabled) {
|
||||
return NextResponse.json({ token: 'anonymous-socket-token' })
|
||||
}
|
||||
@@ -42,4 +43,4 @@ export async function POST() {
|
||||
})
|
||||
return NextResponse.json({ error: 'Failed to generate token' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,17 +1,43 @@
|
||||
import { db, ssoProvider } from '@sim/db'
|
||||
import { db, member, ssoProvider } from '@sim/db'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { REDACTED_MARKER } from '@/lib/core/security/redaction'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
|
||||
const logger = createLogger('SSOProvidersRoute')
|
||||
|
||||
export async function GET() {
|
||||
export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
try {
|
||||
const session = await getSession()
|
||||
const { searchParams } = new URL(request.url)
|
||||
const organizationId = searchParams.get('organizationId')
|
||||
|
||||
let providers
|
||||
if (session?.user?.id) {
|
||||
const userId = session.user.id
|
||||
|
||||
let verifiedOrganizationId: string | null = null
|
||||
if (organizationId) {
|
||||
const [membership] = await db
|
||||
.select({ organizationId: member.organizationId, role: member.role })
|
||||
.from(member)
|
||||
.where(and(eq(member.userId, userId), eq(member.organizationId, organizationId)))
|
||||
.limit(1)
|
||||
if (!membership) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
if (membership.role !== 'owner' && membership.role !== 'admin') {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
verifiedOrganizationId = membership.organizationId
|
||||
}
|
||||
|
||||
const whereClause = verifiedOrganizationId
|
||||
? eq(ssoProvider.organizationId, verifiedOrganizationId)
|
||||
: eq(ssoProvider.userId, userId)
|
||||
|
||||
const results = await db
|
||||
.select({
|
||||
id: ssoProvider.id,
|
||||
@@ -24,19 +50,25 @@ export async function GET() {
|
||||
organizationId: ssoProvider.organizationId,
|
||||
})
|
||||
.from(ssoProvider)
|
||||
.where(eq(ssoProvider.userId, session.user.id))
|
||||
.where(whereClause)
|
||||
|
||||
providers = results.map((provider) => ({
|
||||
...provider,
|
||||
providerType:
|
||||
provider.oidcConfig && provider.samlConfig
|
||||
? 'oidc'
|
||||
: provider.oidcConfig
|
||||
? 'oidc'
|
||||
: provider.samlConfig
|
||||
? 'saml'
|
||||
: ('oidc' as 'oidc' | 'saml'),
|
||||
}))
|
||||
providers = results.map((provider) => {
|
||||
let oidcConfig = provider.oidcConfig
|
||||
if (oidcConfig) {
|
||||
try {
|
||||
const parsed = JSON.parse(oidcConfig)
|
||||
parsed.clientSecret = REDACTED_MARKER
|
||||
oidcConfig = JSON.stringify(parsed)
|
||||
} catch {
|
||||
oidcConfig = null
|
||||
}
|
||||
}
|
||||
return {
|
||||
...provider,
|
||||
oidcConfig,
|
||||
providerType: (provider.samlConfig ? 'saml' : 'oidc') as 'oidc' | 'saml',
|
||||
}
|
||||
})
|
||||
} else {
|
||||
const results = await db
|
||||
.select({
|
||||
@@ -60,4 +92,4 @@ export async function GET() {
|
||||
logger.error('Failed to fetch SSO providers', { error })
|
||||
return NextResponse.json({ error: 'Failed to fetch SSO providers' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { db, member, ssoProvider } from '@sim/db'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { auth, getSession } from '@/lib/auth'
|
||||
@@ -9,6 +11,8 @@ import {
|
||||
validateUrlWithDNS,
|
||||
} from '@/lib/core/security/input-validation.server'
|
||||
import { REDACTED_MARKER } from '@/lib/core/security/redaction'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
|
||||
const logger = createLogger('SSORegisterRoute')
|
||||
|
||||
@@ -32,6 +36,7 @@ const ssoRegistrationSchema = z.discriminatedUnion('providerType', [
|
||||
providerId: z.string().min(1, 'Provider ID is required'),
|
||||
issuer: z.string().url('Issuer must be a valid URL'),
|
||||
domain: z.string().min(1, 'Domain is required'),
|
||||
orgId: z.string().optional(),
|
||||
mapping: mappingSchema,
|
||||
clientId: z.string().min(1, 'Client ID is required for OIDC'),
|
||||
clientSecret: z.string().min(1, 'Client Secret is required for OIDC'),
|
||||
@@ -57,6 +62,7 @@ const ssoRegistrationSchema = z.discriminatedUnion('providerType', [
|
||||
providerId: z.string().min(1, 'Provider ID is required'),
|
||||
issuer: z.string().url('Issuer must be a valid URL'),
|
||||
domain: z.string().min(1, 'Domain is required'),
|
||||
orgId: z.string().optional(),
|
||||
mapping: mappingSchema,
|
||||
entryPoint: z.string().url('Entry point must be a valid URL for SAML'),
|
||||
cert: z.string().min(1, 'Certificate is required for SAML'),
|
||||
@@ -70,7 +76,7 @@ const ssoRegistrationSchema = z.discriminatedUnion('providerType', [
|
||||
}),
|
||||
])
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
try {
|
||||
if (!env.SSO_ENABLED) {
|
||||
return NextResponse.json({ error: 'SSO is not enabled' }, { status: 400 })
|
||||
@@ -107,7 +113,21 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const body = parseResult.data
|
||||
const { providerId, issuer, domain, providerType, mapping } = body
|
||||
const { providerId, issuer, domain, providerType, mapping, orgId } = body
|
||||
|
||||
if (orgId) {
|
||||
const [membership] = await db
|
||||
.select({ organizationId: member.organizationId, role: member.role })
|
||||
.from(member)
|
||||
.where(and(eq(member.userId, session.user.id), eq(member.organizationId, orgId)))
|
||||
.limit(1)
|
||||
if (!membership) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
if (membership.role !== 'owner' && membership.role !== 'admin') {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {}
|
||||
request.headers.forEach((value, key) => {
|
||||
@@ -119,12 +139,13 @@ export async function POST(request: NextRequest) {
|
||||
issuer,
|
||||
domain,
|
||||
mapping,
|
||||
...(orgId ? { organizationId: orgId } : {}),
|
||||
}
|
||||
|
||||
if (providerType === 'oidc') {
|
||||
const {
|
||||
clientId,
|
||||
clientSecret,
|
||||
clientSecret: rawClientSecret,
|
||||
scopes,
|
||||
pkce,
|
||||
authorizationEndpoint,
|
||||
@@ -133,6 +154,34 @@ export async function POST(request: NextRequest) {
|
||||
jwksEndpoint,
|
||||
} = body
|
||||
|
||||
let clientSecret = rawClientSecret
|
||||
if (rawClientSecret === REDACTED_MARKER) {
|
||||
const ownerClause = orgId
|
||||
? and(eq(ssoProvider.providerId, providerId), eq(ssoProvider.organizationId, orgId))
|
||||
: and(eq(ssoProvider.providerId, providerId), eq(ssoProvider.userId, session.user.id))
|
||||
const [existing] = await db
|
||||
.select({ oidcConfig: ssoProvider.oidcConfig })
|
||||
.from(ssoProvider)
|
||||
.where(ownerClause)
|
||||
.limit(1)
|
||||
if (!existing?.oidcConfig) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Cannot update: existing provider not found. Re-enter your client secret.' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
try {
|
||||
clientSecret = JSON.parse(existing.oidcConfig).clientSecret
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Cannot update: failed to read existing secret. Re-enter your client secret.',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const oidcConfig: any = {
|
||||
clientId,
|
||||
clientSecret,
|
||||
@@ -324,7 +373,7 @@ export async function POST(request: NextRequest) {
|
||||
} = body
|
||||
|
||||
const computedCallbackUrl =
|
||||
callbackUrl || `${issuer.replace('/metadata', '')}/callback/${providerId}`
|
||||
callbackUrl || `${getBaseUrl()}/api/auth/sso/saml2/callback/${providerId}`
|
||||
|
||||
const escapeXml = (str: string) =>
|
||||
str.replace(/[<>&"']/g, (c) => {
|
||||
@@ -345,12 +394,34 @@ export async function POST(request: NextRequest) {
|
||||
})
|
||||
|
||||
const spMetadataXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="${escapeXml(issuer)}">
|
||||
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="${escapeXml(getBaseUrl())}">
|
||||
<md:SPSSODescriptor AuthnRequestsSigned="false" WantAssertionsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
|
||||
<md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="${escapeXml(computedCallbackUrl)}" index="1"/>
|
||||
</md:SPSSODescriptor>
|
||||
</md:EntityDescriptor>`
|
||||
|
||||
const certBase64 = cert
|
||||
.replace(/-----BEGIN CERTIFICATE-----/g, '')
|
||||
.replace(/-----END CERTIFICATE-----/g, '')
|
||||
.replace(/\s/g, '')
|
||||
|
||||
const computedIdpMetadataXml =
|
||||
idpMetadata ||
|
||||
`<?xml version="1.0"?>
|
||||
<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" entityID="${escapeXml(issuer)}">
|
||||
<IDPSSODescriptor WantAuthnRequestsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
|
||||
<KeyDescriptor use="signing">
|
||||
<ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
|
||||
<ds:X509Data>
|
||||
<ds:X509Certificate>${certBase64}</ds:X509Certificate>
|
||||
</ds:X509Data>
|
||||
</ds:KeyInfo>
|
||||
</KeyDescriptor>
|
||||
<SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="${escapeXml(entryPoint)}"/>
|
||||
<SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="${escapeXml(entryPoint)}"/>
|
||||
</IDPSSODescriptor>
|
||||
</EntityDescriptor>`
|
||||
|
||||
const samlConfig: any = {
|
||||
entryPoint,
|
||||
cert,
|
||||
@@ -358,7 +429,9 @@ export async function POST(request: NextRequest) {
|
||||
spMetadata: {
|
||||
metadata: spMetadataXml,
|
||||
},
|
||||
mapping,
|
||||
idpMetadata: {
|
||||
metadata: computedIdpMetadataXml,
|
||||
},
|
||||
}
|
||||
|
||||
if (audience) samlConfig.audience = audience
|
||||
@@ -366,14 +439,8 @@ export async function POST(request: NextRequest) {
|
||||
if (signatureAlgorithm) samlConfig.signatureAlgorithm = signatureAlgorithm
|
||||
if (digestAlgorithm) samlConfig.digestAlgorithm = digestAlgorithm
|
||||
if (identifierFormat) samlConfig.identifierFormat = identifierFormat
|
||||
if (idpMetadata) {
|
||||
samlConfig.idpMetadata = {
|
||||
metadata: idpMetadata,
|
||||
}
|
||||
}
|
||||
|
||||
providerConfig.samlConfig = samlConfig
|
||||
providerConfig.mapping = undefined
|
||||
}
|
||||
|
||||
logger.info('Calling Better Auth registerSSOProvider with config:', {
|
||||
@@ -432,9 +499,8 @@ export async function POST(request: NextRequest) {
|
||||
{
|
||||
error: 'Failed to register SSO provider',
|
||||
details: error instanceof Error ? error.message : 'Unknown error',
|
||||
fullError: JSON.stringify(error),
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -3,13 +3,14 @@ import { NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { getCanonicalScopesForProvider } from '@/lib/oauth/utils'
|
||||
|
||||
const logger = createLogger('TrelloAuthorize')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function GET() {
|
||||
export const GET = withRouteHandler(async () => {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
@@ -41,4 +42,4 @@ export async function GET() {
|
||||
logger.error('Error initiating Trello authorization:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||