diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index 0f377392820..b7d6b0d6dc5 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -132,13 +132,13 @@ jobs: lib/workspaces/organization-workspaces.postgres.test.ts lib/billing/calculations/usage-reservation.test.ts - - name: Verify access request pagination and impact in PostgreSQL + - name: Verify access request flows, pagination, and impact in PostgreSQL working-directory: apps/sim env: ACCESS_REQUESTS_TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/sim_access_requests_test run: | bun -e 'import postgres from "postgres"; const sql = postgres(process.env.DATABASE_URL); await sql.unsafe("CREATE DATABASE sim_access_requests_test"); await sql.end()' - bunx vitest run ee/access-requests/lib/repository.postgres.test.ts ee/access-requests/lib/impact.postgres.test.ts + bunx vitest run ee/access-requests/lib/repository.postgres.test.ts ee/access-requests/lib/impact.postgres.test.ts ee/access-requests/lib/application/flow.postgres.test.ts - name: Verify fork previews ignore execution file history in PostgreSQL working-directory: apps/sim diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index 165cd584113..cf026bf4f5b 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -9936,3 +9936,16 @@ export function PitchBookIcon(props: SVGProps) { ) } + +/** TypeSafe’s official mark from https://typesafe.ai. */ +export function TypeSafeIcon(props: SVGProps) { + return ( + + + + ) +} diff --git a/apps/docs/content/docs/platform/self-hosting/environment-variables.mdx b/apps/docs/content/docs/platform/self-hosting/environment-variables.mdx index 23c80273d81..1c9e4b6b197 100644 --- a/apps/docs/content/docs/platform/self-hosting/environment-variables.mdx +++ b/apps/docs/content/docs/platform/self-hosting/environment-variables.mdx @@ -47,6 +47,7 @@ import { Callout } from 'fumadocs-ui/components/callout' | `MISTRAL_API_KEY` | Mistral | | `XAI_API_KEY_1` | xAI | | `KIMI_API_KEY_1` | Moonshot Kimi | +| `TYPESAFE_API_KEY_1` / `_2` / `_3` | TypeSafe Jev hosted key rotation | | `ZAI_API_KEY_1` | Z.ai | | `TOGETHER_API_KEY` | Together AI | | `FIREWORKS_API_KEY` | Fireworks AI | diff --git a/apps/docs/content/docs/workflows/blocks/agent.mdx b/apps/docs/content/docs/workflows/blocks/agent.mdx index d6c270a289b..f754555a240 100644 --- a/apps/docs/content/docs/workflows/blocks/agent.mdx +++ b/apps/docs/content/docs/workflows/blocks/agent.mdx @@ -31,6 +31,30 @@ For a custom cloud deployment, enter its provider prefix and model ID: `azure/my Ollama Cloud, OpenRouter, Fireworks, Together AI, Baseten, Ollama, vLLM, and LiteLLM load their available models from the configured provider. New models appear through that discovery without a Sim catalog release. You can also enter a namespaced ID directly, such as `ollama-cloud/deepseek-v4.1-flash`, `openrouter/provider/model`, or `ollama/my-local-model`. Provider prefixes are case-insensitive; the model ID after the prefix keeps its original casing. +### Jev evaluation models + +Select `jev-latest` from TypeSafe in the Agent model selector. Hosted Sim supplies a key and bills model usage through the normal credit system; workspace or organization BYOK keys override the hosted key without model charges. Self-hosted users enter their TypeSafe key in the block. Use `jev-1.13.0` to pin a version or `jev-preview` to follow preview releases. These models use **State** and **Questions** in place of conversational messages. State accepts text or a reference to a JSON object or array. Questions is a JSON object keyed by the answer names you want: + +```json +{ + "route": { + "type": "choice", + "instructions": "Which team should handle this request?", + "criteria": { "billing": "Payments and invoices", "support": "Product issues" } + }, + "urgency": { + "type": "score", + "instructions": "How urgent is the request?", + "criteria": ["Routine", "Soon", "Immediate"] + }, + "resolved": { "type": "noul", "instructions": "Has the request been resolved?" } +} +``` + +Read results from `` or expand an answer in the reference picker, such as ``. Each Choice answer includes `choice`, `probabilities`, and `confidence`; each Score answer includes `score`, `legend`, `probabilities`, and `confidence`; each Noul answer includes `noul`, a probability from 0 to 1. `content` contains the same answers as JSON text, and the standard model, token, timing, and cost outputs remain available. Use a Condition block to route on these results. + +Jev evaluates the supplied state in one request. Chat messages, files, tools, skills, conversation memory, response-format schemas, and chat model fallbacks are hidden for these models. Saved settings return when you switch back to a chat model. TypeSafe documents a 64,000-token total request limit and a 32,000-token limit for state plus the longest question. See [TypeSafe's model documentation](https://docs.typesafe.ai/models) and [question formats](https://docs.typesafe.ai/api). + ### Files Files for the model to read: images for a vision-capable model, or documents for text. Upload them on the block, or pass a file from an earlier block, such as an upload trigger or an [API](/workflows/blocks/api) response, with a connection tag. @@ -104,7 +128,7 @@ Some settings live under advanced, or appear only for models that support them: - **Max output tokens.** Caps the response length. Defaults to the model's full limit. - **Reasoning effort / Thinking level.** For models with extended reasoning, how much the model thinks before answering. Higher is more thorough but slower and costs more tokens. - **Prompt caching.** For Anthropic Claude models, reuses the system prompt and tool definitions between runs instead of re-reading them every time. Cached input costs a tenth of the normal rate, but writing the cache costs 1.25x, so leave it off for one-off runs and turn it on when the same agent runs repeatedly. The cache covers a prefix only if it reaches 1,024 tokens (2,048 on Haiku) — below that Anthropic ignores it and nothing changes. Entries expire after five minutes of no use. -- **API key.** Your key for the chosen provider. Hidden on hosted Sim, which supplies one. +- **API key.** Your key for the chosen provider. Hidden when hosted Sim supplies a key for the selected model, including Jev. - **Fallback models.** An ordered list of up to five models to try when the request to the selected model fails, whether the provider is overloaded, rate-limited, or down. Sim tries the 2nd choice, then the 3rd, and so on, once each, and `` reports the model that answered. On hosted Sim, hosted models use your workspace's BYOK or platform credentials; local and self-hosted installations may still require a key. A model that needs its own key takes it from a workspace environment variable you pick on the row; a model on the same provider as the selected model reuses the block's key. A stored row key stops applying when its key field is hidden. Providers that require family-specific credentials, such as Vertex, can only be fallbacks for a selected model of the same family. The Auto model cannot be a fallback. A fallback runs with the selected model's settings where its provider accepts them: temperature and max output tokens are clamped to the fallback's limits, and when the fallback has a reasoning effort, thinking level, or verbosity setting that the selected model's value does not fit, the row shows that field so you can pick a value for it; leave it empty and the provider's default applies. - **Retry on fail.** Retries the selected model after a failure, up to a maximum number of tries with a wait between them. When its tries run out, the fallback models are tried in order, once each, with no wait before the first of them. A fallback is never retried. See [Retries and fallbacks](#retries-and-fallbacks) for how recorded tool results are reused and when a tool can execute again. @@ -134,8 +158,8 @@ Live tool-call chips stream for **OpenAI, Anthropic, Azure Anthropic, Google, Ve | Provider | Streamed thinking | Models | |----------|-------------------|--------| -| OpenAI | Summaries only — Requires OpenAI organization verification; falls back to no summaries. | `gpt-6-astra`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-5.5-pro`, `gpt-5.5`, `gpt-5.4-pro`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.4-nano`, `gpt-5.3-codex`, `gpt-5.2-pro`, `gpt-5.2`, `gpt-5.1`, `gpt-5-pro`, `gpt-5`, `gpt-5-mini`, `gpt-5-nano`, `o4-mini`, `o3`, `o3-mini`, `o1` | -| Anthropic | Summaries only — These generations omit full thinking; Sim requests summarized thinking on streaming runs. | `claude-fable-5-1`, `claude-fable-5`, `claude-sonnet-5`, `claude-opus-5`, `claude-opus-4-8`, `claude-opus-4-7`, `claude-opus-4-6`, `claude-sonnet-4-6`, `claude-opus-4-5`, `claude-sonnet-4-5`, `claude-haiku-4-5` | +| OpenAI | Summaries only — Requires OpenAI organization verification; falls back to no summaries. | `gpt-6-astra`, `gpt-6-sol`, `gpt-6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-5.5-pro`, `gpt-5.5`, `gpt-5.4-pro`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.4-nano`, `gpt-5.3-codex`, `gpt-5.2-pro`, `gpt-5.2`, `gpt-5.1`, `gpt-5-pro`, `gpt-5`, `gpt-5-mini`, `gpt-5-nano`, `o4-mini`, `o3`, `o3-mini`, `o1` | +| Anthropic | Summaries only — These generations omit full thinking; Sim requests summarized thinking on streaming runs. | `claude-fable-5-1`, `claude-fable-5`, `claude-sonnet-5`, `claude-opus-5-5`, `claude-opus-5`, `claude-opus-4-8`, `claude-opus-4-7`, `claude-opus-4-6`, `claude-sonnet-4-6`, `claude-opus-4-5`, `claude-sonnet-4-5`, `claude-haiku-4-5` | | Azure OpenAI | Summaries only — Requires OpenAI organization verification; falls back to no summaries. | `azure/gpt-6-astra`, `azure/gpt-5.6-sol`, `azure/gpt-5.6-terra`, `azure/gpt-5.6-luna`, `azure/gpt-5.5`, `azure/gpt-5.4-pro`, `azure/gpt-5.4`, `azure/gpt-5.4-mini`, `azure/gpt-5.4-nano`, `azure/gpt-5.2`, `azure/gpt-5.1`, `azure/gpt-5.1-codex`, `azure/gpt-5`, `azure/gpt-5-mini`, `azure/gpt-5-nano`, `azure/o3`, `azure/o4-mini` | | Azure Anthropic | Summaries only — These generations omit full thinking; Sim requests summarized thinking on streaming runs. | `azure-anthropic/claude-fable-5-1`, `azure-anthropic/claude-opus-5`, `azure-anthropic/claude-opus-4-8`, `azure-anthropic/claude-opus-4-7`, `azure-anthropic/claude-opus-4-6`, `azure-anthropic/claude-opus-4-5`, `azure-anthropic/claude-sonnet-5`, `azure-anthropic/claude-sonnet-4-6`, `azure-anthropic/claude-sonnet-4-5`, `azure-anthropic/claude-opus-4-1`, `azure-anthropic/claude-haiku-4-5` | | Google | Summaries only | `gemini-3.8-flash`, `gemini-3.7-flash`, `gemini-3.6-flash`, `gemini-3.5-flash-lite`, `gemini-3.5-flash`, `gemini-3.1-pro-preview`, `gemini-3.1-flash-lite`, `gemini-3-flash-preview`, `gemini-2.5-pro`, `gemini-2.5-flash`, `gemini-2.5-flash-lite` | diff --git a/apps/sim/app/(interfaces)/chat/components/input/input.tsx b/apps/sim/app/(interfaces)/chat/components/input/input.tsx index 0698fbfda16..b2c6c8b48f1 100644 --- a/apps/sim/app/(interfaces)/chat/components/input/input.tsx +++ b/apps/sim/app/(interfaces)/chat/components/input/input.tsx @@ -7,6 +7,7 @@ import { ArrowUp, Paperclip, StopFilled, X } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { CHAT_ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation' +import { PublicChatActionButton } from '@/app/(interfaces)/chat/components/input/public-chat-action-button' const logger = createLogger('ChatInput') @@ -221,15 +222,14 @@ export const ChatInput: React.FC<{
- +

Attach files

@@ -252,24 +252,22 @@ export const ChatInput: React.FC<{
{isStreaming ? ( - + ) : ( - + )}
diff --git a/apps/sim/app/(interfaces)/chat/components/input/public-chat-action-button.tsx b/apps/sim/app/(interfaces)/chat/components/input/public-chat-action-button.tsx new file mode 100644 index 00000000000..838051a1817 --- /dev/null +++ b/apps/sim/app/(interfaces)/chat/components/input/public-chat-action-button.tsx @@ -0,0 +1,17 @@ +import { type ComponentProps, forwardRef } from 'react' +import { Button } from '@sim/emcn' + +interface PublicChatActionButtonProps + extends Omit< + ComponentProps, + 'variant' | 'size' | 'iconSize' | 'iconPadding' | 'className' + > { + variant: 'primary' | 'quiet' + 'aria-label': string +} + +/** Public chat's circular composer action, retaining its primary and quiet palettes. */ +export const PublicChatActionButton = forwardRef( + (props, ref) => - - - {/* 6px, matching a sidebar section header to its first item — both headers - are an 18px box around 12px text, so equal padding reads as equal - distance. Padding an inner wrapper rather than the animated element: - `collapsible-up`/`-down` interpolate height alone, so a margin here - would hold its full value through the close and then vanish on unmount, - snapping the content below up. */} -
- {steps.map((step, i) => { - const complete = completed[step.id] - return ( - 0 && 'border-t')} - > - - - {step.label} - - - - ) - })} -
-
-
- + + {steps.map((step, i) => { + const complete = completed[step.id] + return ( + 0 && 'border-t')}> + + + {step.label} + + + + ) + })} + ) } diff --git a/apps/sim/app/o/[organizationId]/settings/[section]/settings.tsx b/apps/sim/app/o/[organizationId]/settings/[section]/settings.tsx index a436362048e..18dc35e326b 100644 --- a/apps/sim/app/o/[organizationId]/settings/[section]/settings.tsx +++ b/apps/sim/app/o/[organizationId]/settings/[section]/settings.tsx @@ -47,9 +47,9 @@ const Billing = dynamic(() => const AccessControl = dynamic(() => import('@/ee/access-control/components/access-control').then((m) => m.AccessControl) ) -const OrganizationAccessRequests = dynamic(() => - import('@/ee/access-requests/components/organization-access-requests').then( - (m) => m.OrganizationAccessRequests +const AccessRequestsSettings = dynamic(() => + import('@/ee/access-requests/components/access-requests-settings').then( + (m) => m.AccessRequestsSettings ) ) const AuditLogs = dynamic(() => @@ -111,7 +111,12 @@ export function OrganizationSettings({ section }: OrganizationSettingsProps) { requestsHref={getOrganizationSettingsHref(organizationId, 'requests')} /> )} - {section === 'requests' && } + {section === 'requests' && ( + + )} {section === 'audit-logs' && } {section === 'usage' && ( { it('exposes MCP setup and the read-only roster to an ordinary organization member', () => { expect( organizationSettingsNavigation(false, enterprise, available).map(({ id }) => id) - ).toEqual(['members', 'recently-deleted', 'search-mcp']) + ).toEqual(['members', 'recently-deleted', 'requests', 'search-mcp']) }) it('uses Sources for administration when Search is available', () => { @@ -138,7 +138,7 @@ describe('organization settings navigation', () => { it('hosts the account General section ahead of the organization sections', () => { expect( organizationSurfaceSettingsNavigation(false, enterprise, available).map(({ id }) => id) - ).toEqual(['general', 'members', 'recently-deleted', 'search-mcp']) + ).toEqual(['general', 'members', 'recently-deleted', 'requests', 'search-mcp']) expect(ORGANIZATION_SETTINGS_GROUPS.map(({ key }) => key)).toEqual([ 'account', 'organization', diff --git a/apps/sim/app/workspace/[workspaceId]/access-requests/page.tsx b/apps/sim/app/workspace/[workspaceId]/access-requests/page.tsx index fa84a020a79..56b45eaa5b7 100644 --- a/apps/sim/app/workspace/[workspaceId]/access-requests/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/access-requests/page.tsx @@ -1,19 +1,21 @@ -import { Suspense } from 'react' -import type { Metadata } from 'next' -import { AccessRequestsLoading } from '@/ee/access-requests/components/access-requests-loading' -import { MyAccessRequests } from '@/ee/access-requests/components/my-access-requests' - -export const metadata: Metadata = { title: 'My access requests' } +import { redirect } from 'next/navigation' +import { + getAccessRequestsSettingsHref, + getLegacyAccessRequestsSettingsQuery, +} from '@/ee/access-requests/lib/navigation' interface AccessRequestsPageProps { params: Promise<{ workspaceId: string }> + searchParams: Promise> } -export default async function AccessRequestsPage({ params }: AccessRequestsPageProps) { - const { workspaceId } = await params - return ( - }> - - +export default async function AccessRequestsPage({ + params, + searchParams, +}: AccessRequestsPageProps) { + const [{ workspaceId }, query] = await Promise.all([params, searchParams]) + redirect( + getAccessRequestsSettingsHref({ kind: 'workspace', workspaceId }) + + getLegacyAccessRequestsSettingsQuery(query) ) } diff --git a/apps/sim/app/workspace/[workspaceId]/components/custom-tool-editor/custom-tool-code-field.tsx b/apps/sim/app/workspace/[workspaceId]/components/custom-tool-editor/custom-tool-code-field.tsx index 9290ca41b2b..a7a4bd9bd39 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/custom-tool-editor/custom-tool-code-field.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/custom-tool-editor/custom-tool-code-field.tsx @@ -284,7 +284,6 @@ export function CustomToolCodeField({ }} className='w-64' style={{ - position: 'absolute', top: `${dropdownPosition.top}px`, left: `${dropdownPosition.left}px`, }} @@ -309,7 +308,6 @@ export function CustomToolCodeField({ }} className='w-64' style={{ - position: 'absolute', top: `${dropdownPosition.top}px`, left: `${dropdownPosition.left}px`, }} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx index ef9edea0bc2..958b6b893bf 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx @@ -264,7 +264,7 @@ const MARKDOWN_COMPONENTS = { th({ children, style }: ThProps) { return ( {children} @@ -274,7 +274,7 @@ const MARKDOWN_COMPONENTS = { td({ children, style }: TdProps) { return ( {children} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.tsx index 973bf4587fa..c794699b94d 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.tsx @@ -2,11 +2,12 @@ import { useMemo, useState } from 'react' import { INTEGRATION_METADATA } from '@sim/deployment-config/integration-metadata' -import { ArrowRight, ChevronDown, cn, Expandable, ExpandableContent, OverflowText } from '@sim/emcn' +import { ArrowRight, cn, OverflowText } from '@sim/emcn' import { Table } from '@sim/emcn/icons' import { stripVersionSuffix } from '@sim/utils/string' import { useParams } from 'next/navigation' import { usePostHog } from 'posthog-js/react' +import { HomeSection } from '@/components/home/home-section' import { GmailIcon, SlackIcon } from '@/components/icons' import { resolveOAuthServiceForIntegration, @@ -317,65 +318,36 @@ export function SuggestedActions({ onSelectPrompt }: SuggestedActionsProps) { } return ( -
- {/* Full width so the whole line toggles, not just the label and chevron. */} - - - - {/* 6px, matching a sidebar section header to its first item — both headers - are an 18px box around 12px text, so equal padding reads as equal - distance. Padding an inner wrapper rather than the animated element: - `collapsible-up`/`-down` interpolate height alone, so a margin here - would hold its full value through the close and then vanish on unmount, - snapping the content below up. */} -
- {actions.map((action, i) => { - const Icon = action.icon - return ( - - ) - })} -
-
-
+ {actions.map((action, i) => { + const Icon = action.icon + return ( + + ) + })} + {oauthTarget && workspaceId && ( )} -
+ ) } diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connector-recovery.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connector-recovery.tsx index f95d17a948b..a5f97ed0d7d 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connector-recovery.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connector-recovery.tsx @@ -4,7 +4,10 @@ import { useEffect, useState } from 'react' import { Chip } from '@sim/emcn' import type { ConnectorData } from '@/lib/api/contracts/knowledge/connectors' import { type ResourceScope, resourceScopeFields } from '@/lib/core/resource-scope' -import { CREDENTIAL_REMOVED_SYNC_ERROR } from '@/lib/knowledge/connectors/sync-limits' +import { + CREDENTIAL_REMOVED_SYNC_ERROR, + CREDENTIAL_REVOKED_SYNC_ERROR, +} from '@/lib/knowledge/connectors/sync-limits' import { getCanonicalScopesForProvider, getProviderIdFromServiceId } from '@/lib/oauth' import { getMissingRequiredScopes } from '@/lib/oauth/utils' import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal' @@ -82,7 +85,10 @@ export function ConnectorRecovery({ const docsUrl = isSearchIndex ? connectorDef?.searchDocsUrl : undefined const credentialRemoved = connector.lastSyncError === CREDENTIAL_REMOVED_SYNC_ERROR && !connector.credentialId - const pausedTitle = credentialRemoved + const credentialRevoked = + connector.lastSyncError === CREDENTIAL_REVOKED_SYNC_ERROR && Boolean(connector.credentialId) + const reconnectRequired = credentialRemoved || credentialRevoked + const pausedTitle = reconnectRequired ? 'Reconnect to resume syncing' : 'Sync paused after repeated failures' @@ -97,7 +103,7 @@ export function ConnectorRecovery({ } /> )} - {connector.status === 'disabled' || credentialRemoved ? ( + {connector.status === 'disabled' || reconnectRequired ? ( ({ usePermissionConfig: () => ({ vi.mock('@/ee/access-control/hooks/permission-groups', () => ({ useUserPermissionConfig: () => ({ data: { config: {} }, isPending: false }), })) -vi.mock('@/ee/access-requests/hooks/access-requests', () => ({ +vi.mock('@/hooks/queries/access-requests', () => ({ useDiscoverAccessRequests: () => ({ data: { enabled: false, entries: [] }, isPending: false }), })) vi.mock('@/app/workspace/[workspaceId]/providers/workspace-permissions-provider', () => ({ diff --git a/apps/sim/app/workspace/[workspaceId]/prefetch-access.test.tsx b/apps/sim/app/workspace/[workspaceId]/prefetch-access.test.tsx index 482b16115b4..e1fa4aed93b 100644 --- a/apps/sim/app/workspace/[workspaceId]/prefetch-access.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/prefetch-access.test.tsx @@ -53,7 +53,7 @@ import { PermissionAccessBoundary } from '@/ee/access-requests/components/permis import { accessRequestKeys, workspaceFeatureDiscoveryQuery, -} from '@/ee/access-requests/hooks/access-request-keys' +} from '@/hooks/queries/utils/access-request-keys' import { permissionGroupKeys } from '@/hooks/queries/utils/permission-group-keys' const principal = { kind: 'session', userId: 'viewer', sessionId: 'session' } as const diff --git a/apps/sim/app/workspace/[workspaceId]/prefetch-access.ts b/apps/sim/app/workspace/[workspaceId]/prefetch-access.ts index 1b05ba5913a..6fb0e5162aa 100644 --- a/apps/sim/app/workspace/[workspaceId]/prefetch-access.ts +++ b/apps/sim/app/workspace/[workspaceId]/prefetch-access.ts @@ -11,7 +11,7 @@ import { ACCESS_REQUESTS_STALE_TIME, accessRequestKeys, workspaceFeatureDiscoveryQuery, -} from '@/ee/access-requests/hooks/access-request-keys' +} from '@/hooks/queries/utils/access-request-keys' import { PERMISSION_GROUPS_STALE_TIME, permissionGroupKeys, diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.test.tsx index 63a09c805aa..c48bf2f221b 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.test.tsx @@ -115,6 +115,21 @@ describe('WorkspaceSettingsSectionPage', () => { expect(mockSectionPrefetch).not.toHaveBeenCalled() }) + it('keeps Requests in the current workspace when the organization surface is enabled', async () => { + mockGetHostContext.mockResolvedValue({ + hostOrganizationId: 'org-target', + features: { organizationSearch: true }, + }) + + expect(await WorkspaceSettingsSectionPage(pageProps('requests'))).toBeTruthy() + expect(mockRedirect).not.toHaveBeenCalled() + expect(mockAuthorizeSection).toHaveBeenCalledWith({ + workspaceId: 'workspace-b', + userId: 'viewer-a', + section: 'requests', + }) + }) + it.each(Object.entries(UNIFIED_TO_ORGANIZATION_SECTION))( 'keeps %s in the workspace outside the organization rollout', async (section) => { @@ -137,7 +152,7 @@ describe('WorkspaceSettingsSectionPage', () => { it.each([ { organizationSearch: false, destination: '/workspace/workspace-b/settings/requests' }, - { organizationSearch: true, destination: '/o/org-target/settings/requests' }, + { organizationSearch: true, destination: '/workspace/workspace-b/settings/requests' }, ])( 'moves saved request review tabs to the canonical destination with org rollout=$organizationSearch', async ({ organizationSearch, destination }) => { diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx index e85835b0dca..fa715bb3687 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx @@ -88,9 +88,9 @@ const WorkflowMcpServers = dynamic(() => const AccessControl = dynamic(() => import('@/ee/access-control/components/access-control').then((m) => m.AccessControl) ) -const OrganizationAccessRequests = dynamic(() => - import('@/ee/access-requests/components/organization-access-requests').then( - (m) => m.OrganizationAccessRequests +const AccessRequestsSettings = dynamic(() => + import('@/ee/access-requests/components/access-requests-settings').then( + (m) => m.AccessRequestsSettings ) ) const CustomBlocks = dynamic(() => @@ -191,7 +191,12 @@ function SettingsPageContent({ section }: SettingsPageProps) { /> )} {effectiveSection === 'requests' && organizationId && ( - + )} {effectiveSection === 'custom-blocks' && } {effectiveSection === 'audit-logs' && organizationId && ( diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx index b7b3e19d43f..28bf3a7a232 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx @@ -39,6 +39,7 @@ import { SerperIcon, TinyFishIcon, TogetherIcon, + TypeSafeIcon, WizaIcon, xAIIcon, ZaiIcon, @@ -130,6 +131,13 @@ const PROVIDERS: (BYOKManagerProvider & { id: BYOKProviderId })[] = [ description: 'LLM calls', placeholder: 'sk-...', }, + { + id: 'typesafe', + name: 'TypeSafe', + icon: TypeSafeIcon, + description: 'Jev evaluation models', + placeholder: 'Enter your TypeSafe API key', + }, { id: 'fireworks', name: 'Fireworks', @@ -352,6 +360,7 @@ const PROVIDER_SECTIONS: BYOKProviderSection[] = [ 'cohere', 'xai', 'kimi', + 'typesafe', 'fireworks', 'together', 'baseten', diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal.tsx index 5eae87e1eee..d1d3ceb4b4c 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal.tsx @@ -180,7 +180,7 @@ function FormattedInput({ onClose={envVarProps.onClose} className='w-full' maxHeight='200px' - style={{ position: 'absolute', top: '100%', left: 0, zIndex: 99999 }} + style={{ top: '100%', left: 0, zIndex: 99999 }} /> )} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secret-value-field/secret-value-field.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secret-value-field/secret-value-field.tsx index 0e8dcfdacb2..a843694bfe8 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secret-value-field/secret-value-field.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secret-value-field/secret-value-field.tsx @@ -11,8 +11,10 @@ const VIEWER_MASK_LENGTH = 10 type SecretValueFieldProps = Omit< ComponentProps<'input'>, - 'type' | 'value' | 'onChange' | 'readOnly' + 'type' | 'value' | 'onChange' | 'readOnly' | 'style' > & { + /** The chip owns field styling; callers use className for layout. */ + style?: never value: string onChange?: (value: string) => void /** @@ -47,7 +49,7 @@ export function SecretValueField({ readOnly = false, onFocus, onBlur, - style, + style: _style, className, ...props }: SecretValueFieldProps) { @@ -68,7 +70,6 @@ export function SecretValueField({ type='text' value={displayValue} readOnly - style={style} inputClassName={visuallyMaskEditableValue ? '[-webkit-text-security:disc]' : undefined} onChange={(event) => { if (editable) onChange?.(event.target.value) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts b/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts index 4656a24a217..b89e1795ad0 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts @@ -25,7 +25,7 @@ describe('unified settings navigation', () => { { id: 'desktop', label: 'Desktop', section: 'account' }, { id: 'browser', label: 'Browser', section: 'account' }, { id: 'terminal', label: 'Terminal', section: 'account' }, - { id: 'requests', label: 'Requests', section: 'organization' }, + { id: 'requests', label: 'Requests', section: 'workspace' }, { id: 'access-control', label: 'Permission groups', section: 'organization' }, { id: 'audit-logs', label: 'Audit logs', section: 'organization' }, { id: 'forks', label: 'Workspace forks', section: 'workspace' }, @@ -82,12 +82,12 @@ describe('unified settings navigation', () => { 'apikeys', 'sandboxes', 'recently-deleted', + 'requests', ]) expect(idsForSection('organization')).toEqual([ 'organization', 'usage', 'connected-accounts', - 'requests', 'access-control', 'audit-logs', 'whitelabeling', diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/run-status-control/run-status-control.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/run-status-control/run-status-control.tsx index b71c689b2ae..987dbe29b07 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/run-status-control/run-status-control.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/run-status-control/run-status-control.tsx @@ -1,7 +1,7 @@ 'use client' import { memo } from 'react' -import { Button } from '@sim/emcn' +import { Chip } from '@sim/emcn' import { Loader, Square } from '@sim/emcn/icons' interface RunStatusControlProps { @@ -38,15 +38,9 @@ export const RunStatusControl = memo(function RunStatusControl({ )} - + ) }) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/import-progress-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/import-progress-menu.tsx index 070ec159db8..2d5106d9ada 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/import-progress-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/import-progress-menu.tsx @@ -1,7 +1,7 @@ 'use client' import { - Button, + Chip, DropdownMenu, DropdownMenuContent, DropdownMenuTrigger, @@ -64,20 +64,21 @@ export function ImportProgressMenu({ workspaceId, tableId }: ImportProgressMenuP return ( - + {imports.map((row) => { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/code/code.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/code/code.tsx index 191fa72fb71..4fc1e9f94b8 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/code/code.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/code/code.tsx @@ -564,19 +564,19 @@ export const Code = memo(function Code({ const newVisualLineHeights: number[] = [] const tempContainer = document.createElement('div') - tempContainer.style.cssText = ` - position: absolute; - visibility: hidden; - height: auto; - width: ${preElement.clientWidth}px; - font-family: ${window.getComputedStyle(preElement).fontFamily}; - font-size: ${window.getComputedStyle(preElement).fontSize}; - line-height: ${LINE_HEIGHT_PX}px; - padding: 8px; - white-space: pre-wrap; - word-break: break-word; - box-sizing: border-box; - ` + Object.assign(tempContainer.style, { + position: 'absolute', + visibility: 'hidden', + height: 'auto', + width: `${preElement.clientWidth}px`, + fontFamily: window.getComputedStyle(preElement).fontFamily, + fontSize: window.getComputedStyle(preElement).fontSize, + lineHeight: `${LINE_HEIGHT_PX}px`, + padding: '8px', + whiteSpace: 'pre-wrap', + wordBreak: 'break-word', + boxSizing: 'border-box', + }) document.body.appendChild(tempContainer) lines.forEach((line: string) => { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/condition-input/condition-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/condition-input/condition-input.tsx index f29ef3eae1d..5169f4697fd 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/condition-input/condition-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/condition-input/condition-input.tsx @@ -466,16 +466,16 @@ export function ConditionInput({ // Create a hidden container with the same width as the editor const container = document.createElement('div') - container.style.cssText = ` - position: absolute; - visibility: hidden; - width: ${preElement.clientWidth}px; - font-family: ${window.getComputedStyle(preElement).fontFamily}; - font-size: ${window.getComputedStyle(preElement).fontSize}; - padding: 12px; - white-space: pre-wrap; - word-break: break-word; - ` + Object.assign(container.style, { + position: 'absolute', + visibility: 'hidden', + width: `${preElement.clientWidth}px`, + fontFamily: window.getComputedStyle(preElement).fontFamily, + fontSize: window.getComputedStyle(preElement).fontSize, + padding: '12px', + whiteSpace: 'pre-wrap', + wordBreak: 'break-word', + }) document.body.appendChild(container) // Process each line @@ -487,9 +487,6 @@ export function ConditionInput({ parts.forEach((part) => { const span = document.createElement('span') span.textContent = part - if (part.startsWith('<') && part.endsWith('>')) { - span.style.color = 'rgb(153, 0, 85)' - } lineDiv.appendChild(span) }) } else { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/env-var-dropdown/env-var-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/env-var-dropdown/env-var-dropdown.tsx index 1d2139df30f..05d629563fb 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/env-var-dropdown/env-var-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/env-var-dropdown/env-var-dropdown.tsx @@ -33,7 +33,7 @@ interface EnvVarDropdownProps { /** Callback when the dropdown should close */ onClose?: () => void /** Custom styles for positioning */ - style?: React.CSSProperties + style?: Pick /** Workspace ID for loading workspace-specific environment variables */ workspaceId?: string /** Maximum height for the dropdown */ @@ -286,7 +286,7 @@ export const EnvVarDropdown: React.FC = ({
void /** Custom styles for positioning */ - style?: React.CSSProperties + style?: Pick /** Reference to the input element for caret positioning */ inputRef?: React.RefObject } @@ -1616,7 +1616,7 @@ export const TagDropdown: React.FC = ({
{ diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx index 5f894cfc5f3..d2836aab364 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx @@ -68,7 +68,8 @@ import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowI import { getWorkflowLockToggleIds } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils' import { useDeleteWorkflow, useImportWorkflow } from '@/app/workspace/[workspaceId]/w/hooks' import { RequestAccessModal } from '@/ee/access-requests/components/request-access-action' -import { useDiscoverAccessRequests } from '@/ee/access-requests/hooks/access-requests' +import { getMyAccessRequestHref } from '@/ee/access-requests/lib/navigation' +import { useDiscoverAccessRequests } from '@/hooks/queries/access-requests' import { useCopilotChatSelection } from '@/hooks/queries/copilot-chat-selection' import { type CopilotChatListItem, @@ -261,8 +262,12 @@ export const Panel = memo(function Panel() { if (usageExceeded) { if (usageLimitScope === 'member' && memberLimitTarget) { if (memberLimitTarget.pendingRequestId) { - const params = new URLSearchParams({ requestId: memberLimitTarget.pendingRequestId }) - router.push(`/workspace/${encodeURIComponent(workspaceId)}/access-requests?${params}`) + router.push( + getMyAccessRequestHref( + { kind: 'workspace', workspaceId }, + memberLimitTarget.pendingRequestId + ) + ) } else { setShowLimitRequest(true) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx index cc1deb58045..8b89a41104f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx @@ -68,7 +68,7 @@ vi.mock('@/hooks/use-permission-config', () => ({ }), })) -vi.mock('@/ee/access-requests/hooks/access-requests', () => ({ +vi.mock('@/hooks/queries/access-requests', () => ({ useDiscoverAccessRequests: () => ({ data: { enabled: false, entries: [] }, isPending: false }), })) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.test.tsx index 6d4f0060a19..67c7002b03e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.test.tsx @@ -204,11 +204,11 @@ describe('workspace SettingsSidebar organization rollout', () => { }) it.each(['member', 'external'] as const)( - 'does not expose organization review to a workspace admin who is an org %s', + 'offers own requests to a workspace user who is an org %s', (role) => { hostContext = makeHostContext(role, false) renderSidebar() - expect(workspaceLink('requests')).toBeNull() + expect(workspaceLink('requests')).toHaveTextContent('Requests') } ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx index d4576571666..3593e96d90b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx @@ -167,7 +167,7 @@ export function SettingsSidebar({ ) } if (item.id === 'requests') { - return Boolean(hostContext.hostOrganizationId && isOrgAdminOrOwner) + return Boolean(hostContext.hostOrganizationId) } if (item.id === 'organization') { return Boolean( diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-modal.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-modal.test.tsx index aa1203e2471..438bc083e0c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-modal.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-modal.test.tsx @@ -97,6 +97,72 @@ afterEach(async () => { }) describe('ViewInvitationsModal', () => { + it('keeps multiple workspace invitations and their grants attached to the correct actions', async () => { + const first: MyInvitation = { + ...invitation, + kind: 'workspace', + membershipIntent: 'external', + grants: [ + { + workspaceId: 'alpha', + workspaceName: 'Alpha', + workspaceLogoUrl: 'https://example.com/alpha.png', + permission: 'admin', + }, + ], + joinPreview: { + outcome: 'external', + organizationName: null, + workspaceIdsToMove: [], + workspacesToMove: [], + }, + } + const second: MyInvitation = { + ...first, + id: 'invitation-2', + grants: [ + { + workspaceId: 'design', + workspaceName: 'Design', + workspaceLogoUrl: 'https://example.com/design.png', + permission: 'read', + }, + { workspaceId: 'engineering', workspaceName: 'Engineering', permission: 'write' }, + ], + } + mocks.query.mockReturnValue({ data: [first, second], isPending: false, isError: false }) + await renderModal() + expect(document.body.textContent).not.toContain('Before you join') + expect(document.body.textContent).not.toContain('without joining an organization') + const firstRow = document.querySelector('section[aria-label="Invitation to Alpha"]') + expect(firstRow?.textContent).toContain('admin access') + expect(firstRow?.querySelector('ul')).toBeNull() + expect(firstRow?.querySelector('img')?.getAttribute('src')).toBe( + 'https://example.com/alpha.png' + ) + const secondRow = Array.from(document.querySelectorAll('section')).find( + (row) => row.getAttribute('aria-label') === 'Invitation to Design +1' + ) + expect(secondRow).toBeDefined() + const grants = Array.from(secondRow?.querySelectorAll('li') ?? [], (row) => row.textContent) + expect(grants).toHaveLength(2) + expect(grants[0]).toContain('Designread access') + expect(grants[1]).toContain('Engineeringwrite access') + expect(secondRow?.querySelector('img')?.getAttribute('src')).toBe( + 'https://example.com/design.png' + ) + const accept = Array.from(secondRow?.querySelectorAll('button') ?? []).find( + (button) => button.textContent === 'Accept' + ) + expect(accept).toBeDefined() + await act(async () => accept?.click()) + expect(mocks.accept).toHaveBeenCalledWith({ + invitationId: 'invitation-2', + disclosedWorkspaceIds: [], + disclosedOutcome: 'external', + }) + }) + it('discloses the complete migration and sends exactly those workspace IDs on acceptance', async () => { await renderModal() expect(document.body.textContent).toContain( @@ -137,7 +203,7 @@ describe('ViewInvitationsModal', () => { }) it.each([ - ['external', 'workspace access without joining an organization'], + ['external', null], ['already-member', 'Your organization role will stay the same'], ['blocked', 'This invitation cannot currently be accepted'], ] as const)('discloses %s and preserves its empty-set stale check', async (outcome, message) => { @@ -148,7 +214,11 @@ describe('ViewInvitationsModal', () => { workspaceIdsToMove: [], } await renderModal() - expect(document.body.textContent).toContain(message) + if (message) expect(document.body.textContent).toContain(message) + else { + expect(document.body.textContent).not.toContain('Before you join') + expect(document.body.textContent).not.toContain('without joining an organization') + } expect(document.body.textContent).not.toContain('as an organization admin') await act(async () => button('Accept')?.click()) expect(mocks.accept).toHaveBeenCalledWith({ diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-modal.tsx index 9dd8b438ef2..07640aeff67 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-modal.tsx @@ -1,5 +1,6 @@ 'use client' +import { Fragment } from 'react' import { Chip, ChipModal, @@ -7,19 +8,24 @@ import { ChipModalField, ChipModalFooter, ChipModalHeader, - OverflowText, + ChipModalSeparator, + ChipTag, toast, } from '@sim/emcn' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { useRouter } from 'next/navigation' +import { IdentityTile } from '@/components/identity-tile/identity-tile' import type { MyInvitation } from '@/lib/api/contracts/invitations' import { getInvitationErrorMessage } from '@/lib/invitations/error-messages' +import { getWorkspaceInitial } from '@/lib/workspaces/initials' import { InvitationDisclosure } from '@/app/invite/components/invitation-disclosure' +import { InvitationWorkspaceAccess } from '@/app/invite/components/invitation-workspace-access' import { SettingsEmptyState, SettingsQueryErrorState, } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { useAcceptMyInvitation, useDeclineMyInvitation, @@ -45,13 +51,6 @@ function invitationLabel(inv: MyInvitation): string { return 'Workspace' } -/** Secondary line: who invited, plus role (org) or permission (workspace). */ -function invitationSubLabel(inv: MyInvitation): string { - const invitedBy = inv.inviterName ? `Invited by ${inv.inviterName}` : 'Invited' - const detail = inv.kind === 'organization' ? inv.role : inv.grants[0]?.permission - return detail ? `${invitedBy} · ${detail}` : invitedBy -} - interface ViewInvitationsModalProps { open: boolean onOpenChange: (open: boolean) => void @@ -123,59 +122,101 @@ export function ViewInvitationsModal({ open, onOpenChange }: ViewInvitationsModa ) : invitations.length === 0 ? ( No pending invitations. ) : ( - invitations.map((inv) => { + invitations.map((inv, index) => { const isDisclosureMissing = inv.membershipIntent === 'internal' && !inv.joinPreview + const singleWorkspaceGrant = + inv.kind === 'workspace' && inv.grants.length === 1 ? inv.grants[0] : null + const showJoinNotice = inv.joinPreview + ? inv.joinPreview.outcome !== 'external' + : inv.membershipIntent === 'internal' + const showWorkspaceAccess = !singleWorkspaceGrant && inv.grants.length > 0 + const hasDetails = showJoinNotice || showWorkspaceAccess + const actions = ( +
+ void handleDecline(inv)} + aria-label={`Decline invitation to ${invitationLabel(inv)}`} + > + Decline + + void handleAccept(inv)} + > + Accept + +
+ ) return ( -
-
-
- - + + {index > 0 && } +
+
+
+ + ) + } + iconVariant='custom' + title={invitationLabel(inv)} + description={inv.inviterName ? `Invited by ${inv.inviterName}` : 'Invited'} + badge={ + singleWorkspaceGrant && ( + + {singleWorkspaceGrant.permission} access + + ) + } + /> +
+ {!hasDetails && actions}
-
- - - {isDisclosureMissing && ( - void invitationsQuery.refetch()} - > - Refresh invitation - + {showJoinNotice && ( + + + {isDisclosureMissing && ( + void invitationsQuery.refetch()} + > + Refresh invitation + + )} + )} - -
- void handleDecline(inv)} - aria-label={`Decline invitation to ${invitationLabel(inv)}`} - > - Decline - - void handleAccept(inv)} - > - Accept - -
-
+ {showWorkspaceAccess && ( + + + + )} + {hasDetails &&
{actions}
} + + ) }) )} - onOpenChange(false)} - primaryAction={{ label: 'Done', onClick: () => onOpenChange(false) }} - /> + {invitations.length === 0 && ( + onOpenChange(false)} + /> + )} ) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx index 02d119714c3..f79a8ee3582 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -27,7 +27,6 @@ import { Database, Files, Integration, - ListChecks, MoreHorizontal, PanelLeft, Pin, @@ -850,16 +849,6 @@ export const Sidebar = memo(function Sidebar() { onNavigate: () => handleOpenSettings(id), })) - if (hostContext.hostOrganizationId) { - const accessRequestsHref = `/workspace/${workspaceId}/access-requests` - profileNavigationLinks.push({ - label: 'My access requests', - icon: ListChecks, - href: accessRequestsHref, - onNavigate: () => router.push(accessRequestsHref), - }) - } - const organizationHref = getWorkspaceOrganizationHref(hostContext) if (organizationHref) { profileNavigationLinks.push({ diff --git a/apps/sim/blocks/agent-evaluation.test.ts b/apps/sim/blocks/agent-evaluation.test.ts new file mode 100644 index 00000000000..bd7160a56aa --- /dev/null +++ b/apps/sim/blocks/agent-evaluation.test.ts @@ -0,0 +1,225 @@ +/** @vitest-environment node */ +import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + getEffectiveBlockOutputPaths, + getEffectiveBlockOutputs, + getEffectiveBlockOutputType, +} from '@/lib/workflows/blocks/block-outputs' +import { getBlockReferenceTags } from '@/lib/workflows/blocks/block-reference-tags' +import { evaluateSubBlockCondition } from '@/lib/workflows/subblocks/visibility' +import { AgentBlock } from '@/blocks/blocks/agent' +import { getAgentModelOptions, getModelOptions } from '@/blocks/utils' +import { getBaseModelProviders } from '@/providers/models' +import { Serializer } from '@/serializer' +import { useProvidersStore } from '@/stores/providers/store' +import type { BlockState } from '@/stores/workflows/workflow/types' + +const { mockGetBlock } = vi.hoisted(() => ({ mockGetBlock: vi.fn() })) + +vi.mock('@/blocks', () => ({ getBlock: mockGetBlock })) + +describe('Agent evaluation configuration', () => { + afterEach(resetEnvFlagsMock) + beforeEach(() => { + mockGetBlock.mockReturnValue(AgentBlock) + }) + + it.each(['jev-1.13.0', 'jev-latest', 'jev-preview'])( + 'shows native fields and credentials for %s', + (model) => { + const visible = AgentBlock.subBlocks + .filter((field) => evaluateSubBlockCondition(field.condition, { model })) + .map((field) => field.id) + expect(visible).toEqual(['model', 'apiKey', 'evaluationState', 'evaluationQuestions']) + } + ) + + it('keeps evaluation inputs configurable for a model reference', () => { + for (const field of AgentBlock.subBlocks.filter((field) => field.id.startsWith('evaluation'))) { + expect(evaluateSubBlockCondition(field.condition, { model: '' })).toBe(true) + } + }) + + it.each([false, true])('shows TypeSafe credentials only when needed, hosted=%s', (hosted) => { + setEnvFlags({ isHosted: hosted }) + const apiKey = AgentBlock.subBlocks.find((field) => field.id === 'apiKey')! + expect(evaluateSubBlockCondition(apiKey.condition, { model: 'jev-latest' })).toBe(!hosted) + }) + + it.each(['jev-1.13.0', '', '{{MODEL_ID}}'])( + 'exposes answers for %s in downstream selectors', + (model) => { + const values = { model: { value: model } } + expect(getEffectiveBlockOutputs('agent', values)).toHaveProperty('answers') + expect(getEffectiveBlockOutputPaths('agent', values)).toContain('answers') + expect(getEffectiveBlockOutputType('agent', 'answers', values)).toBe('json') + } + ) + + it('does not expose evaluation answers for a known chat model', () => { + expect(getEffectiveBlockOutputs('agent', { model: { value: 'gpt-4o' } })).not.toHaveProperty( + 'answers' + ) + }) + + it.each(['jev-1.13.0', ''])( + 'keeps answers accessible with a saved chat schema for %s', + (model) => { + const outputs = getEffectiveBlockOutputs('agent', { + model: { value: model }, + responseFormat: { + value: { schema: { type: 'object', properties: { title: { type: 'string' } } } }, + }, + }) + expect(outputs).toHaveProperty('answers') + if (model === 'jev-1.13.0') expect(outputs).not.toHaveProperty('title') + else expect(outputs).toHaveProperty('title') + } + ) + + it('shows Jev only in the model picker that supports evaluation inputs', () => { + useProvidersStore.getState().setProviderModels('base', Object.keys(getBaseModelProviders())) + expect(getAgentModelOptions().map((option) => option.id)).toContain('jev-1.13.0') + expect(getModelOptions().map((option) => option.id)).not.toContain('jev-1.13.0') + }) + + describe('evaluation answer references', () => { + const questions = { + category: { type: 'choice', instructions: 'Choose a category', criteria: { a: 'A', b: 'B' } }, + rating: { type: 'score', instructions: 'Rate the result', criteria: ['Low', 'High'] }, + passed: { type: 'noul', instructions: 'Did it pass?' }, + } + + it.each(['jev-1.13.0', 'jev-latest', 'jev-preview', '', '{{MODEL_ID}}'])( + 'exposes typed question fields for %s without an execution result', + (model) => { + const values = { + model: { value: model }, + evaluationQuestions: { value: JSON.stringify(questions) }, + responseFormat: { + value: { schema: { type: 'object', properties: { title: { type: 'string' } } } }, + }, + } + const tags = getBlockReferenceTags({ + block: { id: 'agent-test', type: 'agent', name: 'Evaluate', subBlocks: values }, + }) + const fields = { + 'answers.category.choice': 'string', + 'answers.category.confidence': 'number', + 'answers.category.probabilities': 'json', + 'answers.category.type': 'string', + 'answers.rating.score': 'number', + 'answers.rating.confidence': 'number', + 'answers.rating.legend': 'json', + 'answers.passed.noul': 'number', + } + for (const [path, type] of Object.entries(fields)) { + expect(tags).toContain(`evaluate.${path}`) + expect(getEffectiveBlockOutputType('agent', path, values)).toBe(type) + } + expect(getEffectiveBlockOutputType('agent', 'answers', values)).toBe('json') + expect(getEffectiveBlockOutputType('agent', 'answers.category', values)).toBe('json') + expect(tags).not.toContain('evaluate.answers.passed.confidence') + expect(tags.includes('evaluate.title')).toBe(!model.startsWith('jev-')) + } + ) + + it.each([undefined, '', '{', '', '{{QUESTIONS}}', [], null, { unknown: {} }])( + 'keeps the answers object selectable when questions cannot be inferred: %j', + (value) => { + const values = { model: { value: 'jev-latest' }, evaluationQuestions: { value } } + expect(getEffectiveBlockOutputPaths('agent', values)).toContain('answers') + expect(getEffectiveBlockOutputType('agent', 'answers', values)).toBe('json') + } + ) + + it('uses structured questions and follows edits without leaking fields into chat models', () => { + const values = { + model: { value: 'jev-latest' }, + evaluationQuestions: { value: { result: questions.category } }, + } + expect(getEffectiveBlockOutputPaths('agent', values)).toContain('answers.result.choice') + expect( + getEffectiveBlockOutputPaths('agent', { + ...values, + evaluationQuestions: { value: { result: questions.passed } }, + }) + ).not.toContain('answers.result.choice') + expect( + getEffectiveBlockOutputPaths('agent', { + ...values, + model: { value: 'gpt-4o' }, + }).some((path) => path.startsWith('answers')) + ).toBe(false) + }) + + it('does not offer ambiguous reference paths for special question IDs', () => { + const values = { + model: { value: 'jev-latest' }, + evaluationQuestions: { + value: { + 'with.dot': questions.passed, + 'with space': questions.passed, + 'with[0]': questions.passed, + '': questions.passed, + 'valid-id_1': questions.passed, + }, + }, + } + const paths = getEffectiveBlockOutputPaths('agent', values) + expect(paths.filter((path) => path.startsWith('answers.'))).toEqual([ + 'answers.valid-id_1.noul', + 'answers.valid-id_1.type', + ]) + expect(getEffectiveBlockOutputType('agent', 'answers', values)).toBe('json') + }) + + it.each(['type', 'properties', 'description', '__proto__'])( + 'resolves the question named %s through schema properties', + (id) => { + const values = { + model: { value: 'jev-latest' }, + evaluationQuestions: { value: { [id]: questions.passed } }, + } + expect(getEffectiveBlockOutputPaths('agent', values)).toContain(`answers.${id}.noul`) + expect(getEffectiveBlockOutputType('agent', `answers.${id}.noul`, values)).toBe('number') + } + ) + }) + + it.each([false, true])( + 'serializes native fields without requiring messages, advanced=%s', + (advancedMode) => { + const values = { + model: 'jev-1.13.0', + apiKey: '{{TYPESAFE_API_KEY}}', + evaluationState: '42', + evaluationQuestions: '{"passed":{"type":"noul","instructions":"Did it pass?"}}', + messages: JSON.stringify([{ role: 'user', content: 'Old chat prompt' }]), + } + const block: BlockState = { + id: 'agent-test', + type: 'agent', + name: 'Evaluator', + position: { x: 0, y: 0 }, + enabled: true, + advancedMode, + outputs: {}, + subBlocks: Object.fromEntries( + Object.entries(values).map(([id, value]) => [ + id, + { id, value, type: AgentBlock.subBlocks.find((field) => field.id === id)!.type }, + ]) + ), + } + const result = new Serializer().serializeWorkflow({ [block.id]: block }, [], {}, {}, true) + expect(result.blocks[0].config.tool).toBe('typesafe') + expect(result.blocks[0].config.params).toMatchObject({ + evaluationState: '42', + evaluationQuestions: values.evaluationQuestions, + }) + expect(result.blocks[0].config.params).not.toHaveProperty('messages') + } + ) +}) diff --git a/apps/sim/blocks/blocks/agent.ts b/apps/sim/blocks/blocks/agent.ts index 82464855edd..6cc01283268 100644 --- a/apps/sim/blocks/blocks/agent.ts +++ b/apps/sim/blocks/blocks/agent.ts @@ -6,8 +6,8 @@ import { getModelFallbackSubBlock, MODEL_FALLBACK_INPUTS } from '@/blocks/model- import type { BlockConfig } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' import { + getAgentModelOptions, getModelCapabilityCondition, - getModelOptions, getProviderCredentialSubBlocks, getSerializedModelProviderId, normalizeFileInput, @@ -15,6 +15,7 @@ import { } from '@/blocks/utils' import { getBaseModelProviders, + getEvaluationModels, getMaxTemperature, getModelsWithDeepResearch, getModelsWithoutMemory, @@ -41,6 +42,8 @@ const MODELS_WITH_THINKING = getModelsWithThinking() const MODELS_WITH_PROMPT_CACHING = getModelsWithPromptCaching() const MODELS_WITH_DEEP_RESEARCH = getModelsWithDeepResearch() const MODELS_WITHOUT_MEMORY = getModelsWithoutMemory() +const EVALUATION_MODELS = getEvaluationModels() +const MODELS_WITHOUT_CHAT_CONTROLS = [...MODELS_WITH_DEEP_RESEARCH, ...EVALUATION_MODELS] interface AgentResponse extends ToolResponse { output: { @@ -82,7 +85,7 @@ export const AgentBlock: BlockConfig = { description: 'Build an agent', authMode: AuthMode.ApiKey, longDescription: - 'The Agent block is a core workflow block that is a wrapper around an LLM. It takes in system/user prompts and calls an LLM provider. It can also make tool calls by directly containing tools inside of its tool input. It can additionally return structured output.', + 'The Agent block is a core workflow block that is a wrapper around an LLM. It takes in system/user prompts and calls an LLM provider. It can also make tool calls by directly containing tools inside of its tool input. It can additionally return structured output. Select a Jev model to evaluate state against typed Choice, Score, and Noul questions and return structured answers.', bestPractices: ` - Prefer using integrations as tools within the agent block over separate integration blocks unless complete determinism needed. - Response Format should be a valid JSON Schema. This determines the output of the agent only if present. Fields can be accessed at root level by the following blocks: e.g. . If response format is not present, the agent will return the standard outputs: content, model, tokens, toolCalls. @@ -105,6 +108,7 @@ export const AgentBlock: BlockConfig = { subBlocks: [ { id: 'messages', + condition: { field: 'model', value: EVALUATION_MODELS, not: true }, title: 'Messages', type: 'messages-input', placeholder: 'Enter messages...', @@ -151,11 +155,41 @@ Return ONLY the JSON array.`, placeholder: 'Type or select a model...', required: true, defaultValue: 'claude-sonnet-5', - options: getModelOptions, + options: getAgentModelOptions, commandSearchable: true, }, + ...getProviderCredentialSubBlocks(), + { + id: 'evaluationState', + title: 'State', + type: 'long-input', + placeholder: 'Content or workflow data to evaluate...', + description: + 'Text, a JSON object, or an array shared by every question. Jev supports text only.', + required: true, + condition: getModelCapabilityCondition(EVALUATION_MODELS), + }, + { + id: 'evaluationQuestions', + title: 'Questions', + type: 'code', + language: 'json', + placeholder: '{"passed":{"type":"noul","instructions":"Did the task succeed?"}}', + description: + 'Questions keyed by ID: choice (1–255 named options), score (2–10 ordered levels), or noul (a yes/no probability). Answers use the same IDs.', + required: true, + condition: getModelCapabilityCondition(EVALUATION_MODELS), + wandConfig: { + enabled: true, + prompt: + 'Generate a JSON object of Jev evaluation questions keyed by descriptive IDs. Every question needs type and instructions. choice: criteria is an object with 1–255 option names mapped to descriptions or null. score: criteria is an array of 2–10 descriptions ordered lowest to highest. noul: criteria is optional, with true and false descriptions. Instructions and descriptions may be text, JSON objects, or arrays. Return only valid JSON. Current questions: {context}', + placeholder: 'Describe the decisions to make...', + generationType: 'json-object', + }, + }, { id: 'attachmentFiles', + condition: { field: 'model', value: EVALUATION_MODELS, not: true }, title: 'Files', type: 'file-upload', canonicalParamId: 'files', @@ -166,6 +200,7 @@ Return ONLY the JSON array.`, }, { id: 'files', + condition: { field: 'model', value: EVALUATION_MODELS, not: true }, title: 'Files', type: 'short-input', canonicalParamId: 'files', @@ -251,7 +286,6 @@ Return ONLY the JSON array.`, }, }, - ...getProviderCredentialSubBlocks(), { id: 'tools', title: 'Tools', @@ -259,7 +293,7 @@ Return ONLY the JSON array.`, defaultValue: [], condition: { field: 'model', - value: MODELS_WITH_DEEP_RESEARCH, + value: MODELS_WITHOUT_CHAT_CONTROLS, not: true, }, }, @@ -270,7 +304,7 @@ Return ONLY the JSON array.`, defaultValue: [], condition: { field: 'model', - value: MODELS_WITH_DEEP_RESEARCH, + value: MODELS_WITHOUT_CHAT_CONTROLS, not: true, }, }, @@ -406,7 +440,7 @@ Return ONLY the JSON array.`, mode: 'advanced', condition: { field: 'model', - value: MODELS_WITH_DEEP_RESEARCH, + value: MODELS_WITHOUT_CHAT_CONTROLS, not: true, }, }, @@ -418,7 +452,7 @@ Return ONLY the JSON array.`, language: 'json', condition: { field: 'model', - value: MODELS_WITH_DEEP_RESEARCH, + value: MODELS_WITHOUT_CHAT_CONTROLS, not: true, }, wandConfig: RESPONSE_FORMAT_WAND_CONFIG, @@ -433,7 +467,10 @@ Return ONLY the JSON array.`, value: MODELS_WITH_DEEP_RESEARCH, }, }, - getModelFallbackSubBlock(), + { + ...getModelFallbackSubBlock(), + condition: { field: 'model', value: EVALUATION_MODELS, not: true }, + }, ], tools: { access: [ @@ -501,6 +538,14 @@ Return ONLY the JSON array.`, }, }, inputs: { + evaluationState: { + type: 'string', + description: 'Content to evaluate: text, a JSON object, or an array', + }, + evaluationQuestions: { + type: 'json', + description: 'Map of question IDs to native Jev Choice, Score, or Noul questions', + }, messages: { type: 'json', description: @@ -601,6 +646,12 @@ Return ONLY the JSON array.`, skills: { type: 'json', description: 'Selected skills configuration' }, }, outputs: { + answers: { + type: 'json', + description: + 'Evaluation answers keyed by question ID: choice, score, or noul, with probabilities and confidence where applicable', + condition: { field: 'model', value: EVALUATION_MODELS, allowReference: true }, + }, content: { type: 'string', description: 'Generated response content' }, model: { type: 'string', description: 'Model used for generation' }, tokens: { type: 'json', description: 'Token usage statistics' }, diff --git a/apps/sim/blocks/utils.ts b/apps/sim/blocks/utils.ts index 92b43f7b483..34a1183d338 100644 --- a/apps/sim/blocks/utils.ts +++ b/apps/sim/blocks/utils.ts @@ -15,6 +15,7 @@ import { getProviderModels, isAutoModel, isCustomModelId, + isEvaluationModel, orderModelIdsByReleaseDate, SIM_AUTO_MODEL_ID, } from '@/providers/models' @@ -52,6 +53,15 @@ export const SERVICE_ACCOUNT_SUBBLOCKS: SubBlockConfig[] = [ * Returns model options for combobox subblocks, combining all provider sources. */ export function getModelOptions() { + return buildModelOptions(false) +} + +/** Agent supports both conversational and native evaluation models. */ +export function getAgentModelOptions() { + return buildModelOptions(true) +} + +function buildModelOptions(includeEvaluation: boolean) { const providersState = useProvidersStore.getState() const baseModels = orderModelIdsByReleaseDate(providersState.providers.base.models) const ollamaModels = providersState.providers.ollama.models @@ -77,7 +87,11 @@ export function getModelOptions() { ) const options = allModels - .filter((model) => getModelSunsetStatus(model) !== 'deprecated') + .filter( + (model) => + getModelSunsetStatus(model) !== 'deprecated' && + (includeEvaluation || !isEvaluationModel(model)) + ) .map((model) => { const icon = getProviderIcon(model) return { label: model, id: model, ...(icon && { icon }) } diff --git a/apps/sim/components/home/home-section.tsx b/apps/sim/components/home/home-section.tsx new file mode 100644 index 00000000000..0b6c8da9b30 --- /dev/null +++ b/apps/sim/components/home/home-section.tsx @@ -0,0 +1,50 @@ +import type { ReactNode } from 'react' +import { cn, Expandable, ExpandableContent } from '@sim/emcn' +import { ChevronDown } from '@sim/emcn/icons' + +interface HomeSectionProps { + title: string + expanded: boolean + animationsEnabled: boolean + onToggle: () => void + children: ReactNode +} + +/** + * Home-page section with caller-owned expansion and animation timing. + * Inner padding collapses with the content; margin would disappear on unmount and cause a jump. + * Section hover or toggle focus reveals the chevron, preserving keyboard feedback when global + * styles clear outlines. A shared transition keeps its fade and rotation synchronized. + */ +export function HomeSection({ + title, + expanded, + animationsEnabled, + onToggle, + children, +}: HomeSectionProps) { + return ( +
+ + + +
{children}
+
+
+
+ ) +} diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index 165cd584113..cf026bf4f5b 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -9936,3 +9936,16 @@ export function PitchBookIcon(props: SVGProps) { ) } + +/** TypeSafe’s official mark from https://typesafe.ai. */ +export function TypeSafeIcon(props: SVGProps) { + return ( + + + + ) +} diff --git a/apps/sim/components/settings/navigation.test.ts b/apps/sim/components/settings/navigation.test.ts index 9725a2e55db..ee85ba89a80 100644 --- a/apps/sim/components/settings/navigation.test.ts +++ b/apps/sim/components/settings/navigation.test.ts @@ -146,6 +146,7 @@ describe('settings navigation boundaries', () => { 'recently-deleted', 'forks', 'custom-blocks', + 'requests', 'self-host', ]) }) @@ -303,7 +304,6 @@ describe('settings navigation boundaries', () => { 'data-drains', 'data-retention', 'organization', - 'requests', 'security', 'sso', 'usage', @@ -320,7 +320,6 @@ describe('settings navigation boundaries', () => { billing: 'billing', 'connected-accounts': 'connected-accounts', 'access-control': 'access-control', - requests: 'requests', 'audit-logs': 'audit-logs', sso: 'sso', security: 'security', @@ -336,6 +335,7 @@ describe('settings navigation boundaries', () => { it('maps every workspace projection from its unified section', () => { expect(UNIFIED_TO_WORKSPACE_SECTION).toEqual({ + requests: 'requests', teammates: 'teammates', secrets: 'secrets', byok: 'byok', @@ -513,14 +513,14 @@ describe('settings navigation boundaries', () => { ).toBe(true) }) - it('limits request settings to organization admins while preserving self-hosted history', () => { + it('allows member requests while reserving management for organization admins', () => { expect( resolveOrganizationSectionAccess({ section: 'requests', isTargetOrganizationMember: true, isTargetOrganizationAdmin: false, }) - ).toBe('unavailable') + ).toBe('view') expect( resolveOrganizationSectionAccess({ section: 'requests', @@ -566,9 +566,10 @@ describe('settings navigation boundaries', () => { 'inbox', 'recently-deleted', 'custom-blocks', + 'requests', 'self-host', ], - mutable: [], + mutable: ['requests'], }, { permission: 'write' as const, @@ -583,9 +584,17 @@ describe('settings navigation boundaries', () => { 'inbox', 'recently-deleted', 'custom-blocks', + 'requests', 'self-host', ], - mutable: ['secrets', 'custom-tools', 'mcp', 'workflow-mcp-servers', 'recently-deleted'], + mutable: [ + 'secrets', + 'custom-tools', + 'mcp', + 'workflow-mcp-servers', + 'recently-deleted', + 'requests', + ], }, { permission: 'admin' as const, @@ -628,6 +637,7 @@ describe('settings navigation boundaries', () => { 'recently-deleted', 'forks', 'custom-blocks', + 'requests', 'self-host', ]) }) diff --git a/apps/sim/components/settings/navigation.ts b/apps/sim/components/settings/navigation.ts index 0a1c44c35d2..d6059f0d5d1 100644 --- a/apps/sim/components/settings/navigation.ts +++ b/apps/sim/components/settings/navigation.ts @@ -62,6 +62,7 @@ export type OrganizationSettingsSection = | 'whitelabeling' export type WorkspaceSettingsSection = + | 'requests' | 'teammates' | 'secrets' | 'byok' @@ -162,7 +163,7 @@ export interface UnifiedSettingsNavigationItem { docsLink?: string /** * The organization-scoped counterpart of this section. Declaring it marks the - * section as acting on the host organization rather than the workspace, which + * section without a workspace projection as acting on the host organization, which * routes it through the organization gate (host organization present, org-admin * viewer, plan entitlement) in both the sidebar and the section page. * @@ -389,11 +390,14 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] icon: ListChecks, unified: { id: 'requests', - description: 'Review requests across your organization.', - group: 'organization', - order: 3, + description: 'Track your requests and browse available access.', + group: 'workspace', + order: 12, organizationSection: 'requests', }, + planes: { + workspace: { id: 'requests', group: 'workspace', order: 12 }, + }, }, { label: 'Permission groups', @@ -873,7 +877,7 @@ export const WORKSPACE_SETTINGS_ITEMS: SettingsNavigationItem = new Set( SETTINGS_SECTION_REGISTRY.flatMap((entry) => - entry.unified?.organizationSection ? [entry.unified.id] : [] + entry.unified?.organizationSection && !entry.planes?.workspace ? [entry.unified.id] : [] ) ) @@ -991,7 +995,7 @@ export const UNIFIED_TO_ORGANIZATION_SECTION: Readonly< Partial> > = Object.fromEntries( SETTINGS_SECTION_REGISTRY.flatMap((entry) => - entry.unified?.organizationSection + entry.unified?.organizationSection && !entry.planes?.workspace ? [[entry.unified.id, entry.unified.organizationSection] as const] : [] ) @@ -1022,7 +1026,8 @@ export function resolveOrganizationSectionAccess({ }: ResolveOrganizationSectionAccessOptions): OrganizationSectionAccess { if (!isTargetOrganizationMember) return 'unavailable' if (section === 'search-mcp' || section === 'recently-deleted') return 'view' - if (section === 'members') return isTargetOrganizationAdmin ? 'manage' : 'view' + if (section === 'members' || section === 'requests') + return isTargetOrganizationAdmin ? 'manage' : 'view' return isTargetOrganizationAdmin ? 'manage' : 'unavailable' } @@ -1190,6 +1195,7 @@ export interface ResolvedWorkspaceNavigationItem } const WORKSPACE_MUTATION_PERMISSION: Record = { + requests: 'read', teammates: 'admin', secrets: 'write', byok: 'admin', @@ -1214,9 +1220,9 @@ export function canMutateWorkspaceSettingsSection( section: WorkspaceSettingsSection, capabilities: WorkspaceMutationCapabilities ): boolean { - return WORKSPACE_MUTATION_PERMISSION[section] === 'admin' - ? capabilities.canAdmin - : capabilities.canEdit + const permission = WORKSPACE_MUTATION_PERMISSION[section] + if (permission === 'read') return true + return permission === 'admin' ? capabilities.canAdmin : capabilities.canEdit } export function resolveWorkspaceNavigation({ diff --git a/apps/sim/connectors/types.ts b/apps/sim/connectors/types.ts index 81eefa34460..2ea305bc5f3 100644 --- a/apps/sim/connectors/types.ts +++ b/apps/sim/connectors/types.ts @@ -266,6 +266,7 @@ export const SYNC_SKIP_REASONS = [ 'sync_superseded', 'connector_deleted_during_sync', 'credential_missing', + 'credential_revoked', ] as const export type SyncSkipReason = (typeof SYNC_SKIP_REASONS)[number] diff --git a/apps/sim/ee/access-requests/README.md b/apps/sim/ee/access-requests/README.md index 8031a07b5d7..98345bd6e61 100644 --- a/apps/sim/ee/access-requests/README.md +++ b/apps/sim/ee/access-requests/README.md @@ -1,6 +1,6 @@ # Permission access requests -Members request access from locked features or the block picker and track their requests through **My access requests** in the profile menu. The history page offers **Browse access** when additional access is requestable and identifies its workspace or organization scope. Organization owners and administrators review requests in **Organization settings → Requests** or through an authenticated email link. Requests remain available outside the Enterprise permission-group settings because the same queue handles increases to an administrator-set member credit cap. +Members request access from locked features or the block picker and track their requests in **Settings → Requests** on both workspace and organization surfaces. **My requests** shows the current scope's history, including the member's credit-limit requests, and **Browse access** lists additional requestable access. Organization administrators also have **Review requests** for the organization-wide queue. Saved requester links and authenticated email links resolve to settings; the standalone entry remains available outside the organization Search rollout. Requests are independent of the Enterprise permission-group settings because the same queue handles increases to an administrator-set member credit cap. ## Deployment diff --git a/apps/sim/ee/access-requests/components/access-request-review.test.tsx b/apps/sim/ee/access-requests/components/access-request-review.test.tsx index dc7366e0aaa..01d464f0507 100644 --- a/apps/sim/ee/access-requests/components/access-request-review.test.tsx +++ b/apps/sim/ee/access-requests/components/access-request-review.test.tsx @@ -13,7 +13,7 @@ vi.mock('@sim/emcn', async (importOriginal) => ({ })) const mocks = vi.hoisted(() => ({ preview: vi.fn(), resolve: vi.fn() })) -vi.mock('@/ee/access-requests/hooks/access-requests', () => ({ +vi.mock('@/hooks/queries/access-requests', () => ({ useAccessRequestPreview: mocks.preview, useResolveAccessRequest: mocks.resolve, })) diff --git a/apps/sim/ee/access-requests/components/access-request-review.tsx b/apps/sim/ee/access-requests/components/access-request-review.tsx index 6772705a1fc..bda96ad2607 100644 --- a/apps/sim/ee/access-requests/components/access-request-review.tsx +++ b/apps/sim/ee/access-requests/components/access-request-review.tsx @@ -13,10 +13,7 @@ import { } from '@sim/emcn' import { PolicyChanges } from '@/ee/access-requests/components/policy-changes' import { ACCESS_REQUEST_STATUS_LABELS } from '@/ee/access-requests/components/status' -import { - useAccessRequestPreview, - useResolveAccessRequest, -} from '@/ee/access-requests/hooks/access-requests' +import { useAccessRequestPreview, useResolveAccessRequest } from '@/hooks/queries/access-requests' interface AccessRequestReviewProps { organizationId: string diff --git a/apps/sim/ee/access-requests/components/my-access-requests.test.tsx b/apps/sim/ee/access-requests/components/access-requests-settings.test.tsx similarity index 65% rename from apps/sim/ee/access-requests/components/my-access-requests.test.tsx rename to apps/sim/ee/access-requests/components/access-requests-settings.test.tsx index 3a97c225526..a263c976de2 100644 --- a/apps/sim/ee/access-requests/components/my-access-requests.test.tsx +++ b/apps/sim/ee/access-requests/components/access-requests-settings.test.tsx @@ -1,11 +1,12 @@ /** * @vitest-environment jsdom */ -import { act } from 'react' +import { act, type ComponentProps } from 'react' import { NuqsTestingAdapter } from 'nuqs/adapters/testing' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { MyAccessRequests } from '@/ee/access-requests/components/my-access-requests' +import { SettingsHeaderProvider, SettingsHeaderShell } from '@/components/settings/settings-header' +import { AccessRequestsSettings } from '@/ee/access-requests/components/access-requests-settings' const mocks = vi.hoisted(() => ({ mine: vi.fn(), @@ -13,15 +14,24 @@ const mocks = vi.hoisted(() => ({ cancel: vi.fn(), workspace: vi.fn(), hosted: true, + review: vi.fn(), + push: vi.fn(), url: vi.fn(), })) +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: mocks.push }), + usePathname: () => '/workspace/workspace/settings/requests', +})) +vi.mock('@/ee/access-requests/components/organization-access-requests', () => ({ + OrganizationAccessRequests: mocks.review, +})) vi.mock('@/lib/core/config/deployment-shape', () => ({ useDeploymentShape: () => ({ hosted: mocks.hosted }), })) vi.mock('@/hooks/queries/workspace-host', () => ({ useWorkspaceHostContextQuery: mocks.workspace, })) -vi.mock('@/ee/access-requests/hooks/access-requests', () => ({ +vi.mock('@/hooks/queries/access-requests', () => ({ ACCESS_REQUEST_PAGE_SIZE: 25, useMyAccessRequests: mocks.mine, useDiscoverAccessRequests: mocks.discovery, @@ -69,11 +79,18 @@ describe('compact requester history', () => { act(() => root.unmount()) container.remove() }) - const render = (searchParams = '') => + const render = ( + searchParams = '', + props: Partial> = {} + ) => act(() => root.render( - + + + + + ) ) @@ -85,6 +102,28 @@ describe('compact requester history', () => { expect(container.textContent).not.toContain('Cancel') expect(container.querySelector('[aria-label="View request for Slack"]')).not.toBeNull() }) + + it('uses organization scope inside the organization shell without a workspace exit link', () => { + const organizationScope = { kind: 'organization', organizationId: 'organization' } as const + render('', { scope: organizationScope }) + expect(mocks.mine).toHaveBeenCalledWith(organizationScope, 0, undefined, true) + expect(container.textContent).not.toContain('Workspace:') + expect(container.textContent).not.toContain('Your workspaces') + expect(container.textContent).not.toContain('Back to Sim') + }) + + it('returns standalone visitors through the shared app entry', () => { + render('', { + scope: { kind: 'organization', organizationId: 'organization' }, + standalone: true, + }) + const back = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'Back to Sim' + ) + expect(back).toBeDefined() + act(() => back!.click()) + expect(mocks.push).toHaveBeenCalledWith('/home') + }) it('loads a deep-linked request independently of the retained list page', () => { render('?page=3&requestId=request') expect(mocks.mine).toHaveBeenCalledWith(scope, 75, undefined, true) @@ -92,6 +131,14 @@ describe('compact requester history', () => { expect(document.querySelector('[role="dialog"]')?.textContent).toContain(request.reason) expect(document.querySelector('[role="dialog"]')?.textContent).toContain('Cancel request') }) + it('leaves the list visible when a deep-linked request no longer exists', () => { + mocks.mine.mockImplementation((_scope, _offset, id) => successful(id ? [] : [request])) + render('?page=3&requestId=removed-request') + expect(mocks.mine).toHaveBeenCalledWith(scope, 0, 'removed-request') + expect(container.textContent).toContain('Slack') + expect(container.textContent).toContain('Page 4') + expect(document.querySelector('[role="dialog"]')).toBeNull() + }) it('does not show cached detail or allow cancellation after an authorized detail refresh fails', () => { mocks.mine.mockImplementation((_scope, _offset, id) => id @@ -130,7 +177,7 @@ describe('compact requester history', () => { expect(container.querySelector('[aria-label="Access request views"]')).toBeNull() await vi.waitFor(() => expect(mocks.url).toHaveBeenLastCalledWith( - expect.objectContaining({ queryString: '?search=slack' }) + expect.objectContaining({ queryString: '?view=requests&search=slack' }) ) ) expect(mocks.mine).toHaveBeenLastCalledWith(scope, 0, undefined, true) @@ -215,22 +262,50 @@ describe('compact requester history', () => { isSuccess: true, }) render('?view=catalog') - expect(container.textContent).toContain('New requests are paused') + expect(container.textContent).toContain('Your organization has paused new requests.') expect(container.querySelector('[aria-label="Search access catalog"]')).toBeNull() expect(container.querySelector('[role="radio"][value="requests"]')).not.toBeNull() }) - it('labels the current workspace and organization-wide credit request scope', () => { + it('uses the settings shell context without repeating scope notices', () => { render() - expect(container.textContent).toContain('Workspace: Design') - expect(container.textContent).toContain('Includes your organization credit limit requests.') + expect(container.textContent).not.toContain('Workspace: Design') + expect(container.textContent).not.toContain('Includes your organization credit limit requests.') }) - it('does not offer credit requests on self-hosted deployments', () => { - mocks.hosted = false - mocks.mine.mockReturnValue(successful([])) - render() - expect(container.textContent).not.toContain('credit') - expect(container.textContent).toContain('Workspace: Design') + it('does not mount reviewer queries for members even with a forged review view', () => { + render('?view=review&request-id=other-request') + expect(mocks.review).not.toHaveBeenCalled() + expect(mocks.mine).toHaveBeenCalledWith(scope, 0, undefined, true) + expect(container.textContent).not.toContain('Review requests') + expect(container.textContent).not.toContain('Allow requests') + }) + + it('preserves the default administrator review destination and exposes My requests', async () => { + render('', { reviewOrganizationId: 'organization' }) + expect(mocks.review).toHaveBeenCalledWith( + expect.objectContaining({ organizationId: 'organization' }), + undefined + ) + expect(mocks.mine).toHaveBeenCalledWith(scope, 0, undefined, false) + expect( + container.querySelector('[role="radio"][value="review"]')?.getAttribute('aria-checked') + ).toBe('true') + await act(async () => + container.querySelector('[role="radio"][value="requests"]')!.click() + ) + expect(mocks.mine).toHaveBeenLastCalledWith(scope, 0, undefined, true) + await vi.waitFor(() => + expect(mocks.url).toHaveBeenLastCalledWith( + expect.objectContaining({ queryString: '?view=requests' }) + ) + ) + }) + + it('opens an administrator own-request deep link without mounting the review queue', () => { + mocks.mine.mockImplementation((_scope, _offset, id) => successful(id ? [] : [request])) + render('?view=requests&requestId=missing', { reviewOrganizationId: 'organization' }) + expect(mocks.review).not.toHaveBeenCalled() + expect(container.textContent).toContain('Slack') }) }) diff --git a/apps/sim/ee/access-requests/components/access-requests-settings.tsx b/apps/sim/ee/access-requests/components/access-requests-settings.tsx new file mode 100644 index 00000000000..60c9478888b --- /dev/null +++ b/apps/sim/ee/access-requests/components/access-requests-settings.tsx @@ -0,0 +1,231 @@ +'use client' + +import { Chip, ChipSwitch, ChipTag } from '@sim/emcn' +import { Lock } from '@sim/emcn/icons' +import { useRouter } from 'next/navigation' +import { useQueryStates } from 'nuqs' +import { SettingsPanel } from '@/components/settings/settings-panel' +import type { AccessRequestScope } from '@/lib/api/contracts/access-requests' +import { APP_ENTRY_PATH } from '@/lib/navigation/paths' +import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' +import { + SettingsEmptyState, + SettingsQueryErrorState, +} from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { + RESOURCE_LIST_STACK, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import { MyAccessRequestDetails } from '@/ee/access-requests/components/my-access-request-details' +import { OrganizationAccessRequests } from '@/ee/access-requests/components/organization-access-requests' +import { RequestAccessAction } from '@/ee/access-requests/components/request-access-action' +import { + accessRequestSettingsSearchParams, + accessRequestUrlOptions, +} from '@/ee/access-requests/components/search-params' +import { ACCESS_REQUEST_STATUS_LABELS } from '@/ee/access-requests/components/status' +import { ACCESS_REQUEST_MAX_SEARCH_LENGTH } from '@/ee/access-requests/lib/constants' +import { + ACCESS_REQUEST_PAGE_SIZE, + useDiscoverAccessRequests, + useMyAccessRequests, +} from '@/hooks/queries/access-requests' +import { useDebounce } from '@/hooks/use-debounce' +import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' + +interface AccessRequestsSettingsProps { + scope: AccessRequestScope + reviewOrganizationId?: string + standalone?: boolean +} + +export function AccessRequestsSettings({ + scope, + reviewOrganizationId, + standalone = false, +}: AccessRequestsSettingsProps) { + const router = useRouter() + const actions = standalone + ? [{ text: 'Back to Sim', onSelect: () => router.push(APP_ENTRY_PATH) }] + : undefined + const [{ view: selectedView, search, page, requestId }, setParams] = useQueryStates( + accessRequestSettingsSearchParams, + accessRequestUrlOptions + ) + const view = + selectedView === 'review' && !reviewOrganizationId + ? 'requests' + : (selectedView ?? (reviewOrganizationId ? 'review' : 'requests')) + const setSearch = useDebouncedSearchSetter((value, options) => + setParams({ search: value, page: 0 }, options) + ) + const debouncedSearch = useDebounce(search.trim(), SEARCH_DEBOUNCE_MS) + const searchPending = view === 'catalog' && search.trim() !== debouncedSearch + const offset = page * ACCESS_REQUEST_PAGE_SIZE + const requests = useMyAccessRequests(scope, offset, undefined, view === 'requests') + const catalog = useDiscoverAccessRequests({ + ...scope, + search: view === 'catalog' ? debouncedSearch : '', + state: 'requestable', + limit: view === 'catalog' ? ACCESS_REQUEST_PAGE_SIZE : 1, + offset: view === 'catalog' ? offset : 0, + }) + const currentQuery = view === 'requests' ? requests : catalog + const showCatalog = + view === 'catalog' || + (catalog.isSuccess && catalog.data.enabled && catalog.data.entries.length > 0) + const requestsPaused = + catalog.isSuccess && !catalog.data.enabled && Boolean(catalog.data.organizationId) + return ( +
+ {(showCatalog || reviewOrganizationId) && ( + + void setParams({ view: value, page: 0, requestId: null, 'request-id': null }) + } + /> + )} + {view === 'review' && reviewOrganizationId ? ( + + ) : ( + + {view === 'requests' && requestsPaused && ( +

+ Your organization has paused new requests. Your request history is still available. +

+ )} + {searchPending || currentQuery.isPending ? ( + + Loading... + + ) : currentQuery.isError ? ( + void currentQuery.refetch()} + /> + ) : view === 'requests' ? ( +
+ {requests.data?.requests.length === 0 && ( + + {page > 0 + ? 'No requests on this page. Go to the previous page to see your requests.' + : 'No requests yet. Requests you send will appear here.'} + + )} + {requests.data?.requests.map((request) => ( + {ACCESS_REQUEST_STATUS_LABELS[request.status]} + } + onClick={() => void setParams({ requestId: request.id }, { history: 'push' })} + clickLabel={`View request for ${request.targetLabel}`} + /> + ))} +
+ ) : !catalog.data?.enabled ? ( + + {requestsPaused + ? 'Your organization has paused new requests. Your request history is still available.' + : 'Access requests are available in organization workspaces.'} + + ) : ( +
+ {catalog.data.entries.length === 0 && ( + +
+ + {debouncedSearch + ? 'No matching results. Try another search.' + : page > 0 + ? 'No more access to request.' + : `Nothing to request in this ${scope.kind}.`} + + {debouncedSearch && setSearch('')}>Clear search} +
+
+ )} + {catalog.data.entries.map((entry) => ( + } + iconVariant='plain' + title={entry.label} + badge={ + entry.state === 'allowed' ? ( + Available + ) : undefined + } + trailing={ + entry.state === 'requestable' ? ( + + void setParams({ requestId }, { history: 'push' }) + } + /> + ) : undefined + } + /> + ))} +
+ )} + {(page > 0 || currentQuery.data?.hasMore) && ( +
+ void setParams({ page: page - 1 })} + > + Previous + + Page {page + 1} + void setParams({ page: page + 1 })} + > + Next + +
+ )} + {requestId && ( + void setParams({ requestId: null })} + /> + )} +
+ )} +
+ ) +} diff --git a/apps/sim/ee/access-requests/components/member-limit-request-action.tsx b/apps/sim/ee/access-requests/components/member-limit-request-action.tsx index f02409de2ad..b5d6be6f38f 100644 --- a/apps/sim/ee/access-requests/components/member-limit-request-action.tsx +++ b/apps/sim/ee/access-requests/components/member-limit-request-action.tsx @@ -2,7 +2,7 @@ import type { AccessRequestScope } from '@/lib/api/contracts/access-requests' import { RequestAccessAction } from '@/ee/access-requests/components/request-access-action' -import { useDiscoverAccessRequests } from '@/ee/access-requests/hooks/access-requests' +import { useDiscoverAccessRequests } from '@/hooks/queries/access-requests' interface MemberLimitRequestActionProps { scope: AccessRequestScope diff --git a/apps/sim/ee/access-requests/components/my-access-request-details.tsx b/apps/sim/ee/access-requests/components/my-access-request-details.tsx index 12b69995577..1cc9cc1d181 100644 --- a/apps/sim/ee/access-requests/components/my-access-request-details.tsx +++ b/apps/sim/ee/access-requests/components/my-access-request-details.tsx @@ -13,10 +13,7 @@ import { } from '@sim/emcn' import type { AccessRequestScope } from '@/lib/api/contracts/access-requests' import { ACCESS_REQUEST_STATUS_LABELS } from '@/ee/access-requests/components/status' -import { - useCancelAccessRequest, - useMyAccessRequests, -} from '@/ee/access-requests/hooks/access-requests' +import { useCancelAccessRequest, useMyAccessRequests } from '@/hooks/queries/access-requests' interface MyAccessRequestDetailsProps { scope: AccessRequestScope @@ -30,6 +27,8 @@ export function MyAccessRequestDetails({ scope, requestId, onClose }: MyAccessRe const request = details.isSuccess ? details.data.requests[0] : undefined const title = request ? `${request.targetLabel} request` : 'Access request' + if (details.isSuccess && !request) return null + return ( void details.refetch()}>Try again )} - {details.isSuccess && !request && ( -

- This request is no longer available. -

- )} {request && ( <>
diff --git a/apps/sim/ee/access-requests/components/my-access-requests.tsx b/apps/sim/ee/access-requests/components/my-access-requests.tsx deleted file mode 100644 index 17c005c982e..00000000000 --- a/apps/sim/ee/access-requests/components/my-access-requests.tsx +++ /dev/null @@ -1,239 +0,0 @@ -'use client' - -import { Chip, ChipInput, ChipLink, ChipSwitch, ChipTag } from '@sim/emcn' -import { Lock, Search } from '@sim/emcn/icons' -import { useQueryStates } from 'nuqs' -import { EmptyState } from '@/components/empty-state/empty-state' -import type { AccessRequestScope } from '@/lib/api/contracts/access-requests' -import { useDeploymentShape } from '@/lib/core/config/deployment-shape' -import { WORKSPACES_PATH } from '@/lib/navigation/paths' -import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' -import { - RESOURCE_LIST_STACK, - SettingsResourceRow, -} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' -import { MyAccessRequestDetails } from '@/ee/access-requests/components/my-access-request-details' -import { RequestAccessAction } from '@/ee/access-requests/components/request-access-action' -import { - accessRequestSearchParams, - accessRequestUrlOptions, -} from '@/ee/access-requests/components/search-params' -import { ACCESS_REQUEST_STATUS_LABELS } from '@/ee/access-requests/components/status' -import { - ACCESS_REQUEST_PAGE_SIZE, - useDiscoverAccessRequests, - useMyAccessRequests, -} from '@/ee/access-requests/hooks/access-requests' -import { ACCESS_REQUEST_MAX_SEARCH_LENGTH } from '@/ee/access-requests/lib/constants' -import { useWorkspaceHostContextQuery } from '@/hooks/queries/workspace-host' -import { useDebounce } from '@/hooks/use-debounce' -import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' - -interface MyAccessRequestsProps { - scope: AccessRequestScope -} - -export function MyAccessRequests({ scope }: MyAccessRequestsProps) { - const { hosted } = useDeploymentShape() - const workspace = useWorkspaceHostContextQuery( - scope.kind === 'workspace' ? scope.workspaceId : '' - ) - const [{ view, search, page, requestId }, setParams] = useQueryStates( - accessRequestSearchParams, - accessRequestUrlOptions - ) - const setSearch = useDebouncedSearchSetter((value, options) => - setParams({ search: value, page: 0 }, options) - ) - const debouncedSearch = useDebounce(search.trim(), SEARCH_DEBOUNCE_MS) - const searchPending = view === 'catalog' && search.trim() !== debouncedSearch - const offset = page * ACCESS_REQUEST_PAGE_SIZE - const requests = useMyAccessRequests(scope, offset, undefined, view === 'requests') - const catalog = useDiscoverAccessRequests({ - ...scope, - search: view === 'catalog' ? debouncedSearch : '', - state: 'requestable', - limit: view === 'catalog' ? ACCESS_REQUEST_PAGE_SIZE : 1, - offset: view === 'catalog' ? offset : 0, - }) - const currentQuery = view === 'requests' ? requests : catalog - const showCatalog = - view === 'catalog' || - (catalog.isSuccess && catalog.data.enabled && catalog.data.entries.length > 0) - const requestsPaused = - catalog.isSuccess && !catalog.data.enabled && Boolean(catalog.data.organizationId) - const scopeLabel = - scope.kind === 'workspace' - ? `Workspace: ${workspace.isSuccess ? workspace.data.workspace.name : 'Current workspace'}` - : 'Organization requests' - - return ( -
-
-
-
-

My access requests

-

{scopeLabel}

- {hosted && scope.kind === 'workspace' && workspace.data?.hostOrganizationId && ( -

- Includes your organization credit limit requests. -

- )} -
- {scope.kind === 'organization' && ( - Your workspaces - )} -
- {showCatalog && ( - void setParams({ view: value, page: 0, requestId: null })} - /> - )} - {view === 'requests' && requestsPaused && ( -

- Your organization has paused new requests. Your request history is still available. -

- )} - {view === 'catalog' && catalog.isSuccess && catalog.data.enabled && ( - setSearch(event.target.value)} - maxLength={ACCESS_REQUEST_MAX_SEARCH_LENGTH} - placeholder='Search features, integrations, and models...' - aria-label='Search access catalog' - /> - )} - {searchPending || currentQuery.isPending ? ( -

- Loading... -

- ) : currentQuery.isError ? ( - void currentQuery.refetch()}>Try again} - /> - ) : view === 'requests' ? ( -
- {requests.data?.requests.length === 0 && ( - 0 ? 'No requests on this page' : 'No access requests yet'} - description={ - page > 0 - ? 'Go to the previous page to see your requests.' - : 'Requests you send appear here so you can track their status.' - } - /> - )} - {requests.data?.requests.map((request) => ( - {ACCESS_REQUEST_STATUS_LABELS[request.status]} - } - onClick={() => void setParams({ requestId: request.id }, { history: 'push' })} - clickLabel={`View request for ${request.targetLabel}`} - navigable - /> - ))} -
- ) : !catalog.data?.enabled ? ( - - ) : ( -
- {catalog.data.entries.length === 0 && ( - 0 - ? 'No more access to request' - : `Nothing to request in this ${scope.kind}` - } - description={ - debouncedSearch - ? 'Try another search or clear it to see available requests.' - : page > 0 - ? 'Go to the previous page to see available requests.' - : 'There is no additional access available to request.' - } - action={ - debouncedSearch ? ( - setSearch('')}>Clear search - ) : undefined - } - /> - )} - {catalog.data.entries.map((entry) => ( - } - iconVariant='plain' - title={entry.label} - badge={ - entry.state === 'allowed' ? ( - Available - ) : undefined - } - trailing={ - entry.state === 'requestable' ? ( - - void setParams({ requestId }, { history: 'push' }) - } - /> - ) : undefined - } - /> - ))} -
- )} - {(page > 0 || currentQuery.data?.hasMore) && ( -
- void setParams({ page: page - 1 })} - > - Previous - - Page {page + 1} - void setParams({ page: page + 1 })} - > - Next - -
- )} -
- {requestId && ( - void setParams({ requestId: null })} - /> - )} -
- ) -} diff --git a/apps/sim/ee/access-requests/components/organization-access-requests.test.tsx b/apps/sim/ee/access-requests/components/organization-access-requests.test.tsx index ba7441854d1..8b52c547f6b 100644 --- a/apps/sim/ee/access-requests/components/organization-access-requests.test.tsx +++ b/apps/sim/ee/access-requests/components/organization-access-requests.test.tsx @@ -11,7 +11,7 @@ const mocks = vi.hoisted(() => ({ mutate: vi.fn(), refetch: vi.fn(), })) -vi.mock('@/ee/access-requests/hooks/access-requests', () => ({ +vi.mock('@/hooks/queries/access-requests', () => ({ ACCESS_REQUEST_PAGE_SIZE: 25, useAccessRequestSettings: mocks.settings, useOrganizationAccessRequests: mocks.requests, diff --git a/apps/sim/ee/access-requests/components/organization-access-requests.tsx b/apps/sim/ee/access-requests/components/organization-access-requests.tsx index d192502a01a..40ed426fa99 100644 --- a/apps/sim/ee/access-requests/components/organization-access-requests.tsx +++ b/apps/sim/ee/access-requests/components/organization-access-requests.tsx @@ -1,8 +1,8 @@ 'use client' -import { Chip, ChipDropdown, ChipInput, ChipSwitch, ChipTag, toast } from '@sim/emcn' -import { Search } from '@sim/emcn/icons' +import { Chip, ChipDropdown, ChipSwitch, ChipTag, toast } from '@sim/emcn' import { useQueryStates } from 'nuqs' +import type { SettingsAction } from '@/components/settings/settings-header' import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' import { SettingsEmptyState, @@ -20,28 +20,27 @@ import { accessReviewSearchParams, } from '@/ee/access-requests/components/search-params' import { ACCESS_REQUEST_STATUS_LABELS } from '@/ee/access-requests/components/status' +import { ACCESS_REQUEST_MAX_SEARCH_LENGTH } from '@/ee/access-requests/lib/constants' import { ACCESS_REQUEST_PAGE_SIZE, useAccessRequestSettings, useOrganizationAccessRequests, useUpdateAccessRequestSettings, -} from '@/ee/access-requests/hooks/access-requests' -import { ACCESS_REQUEST_MAX_SEARCH_LENGTH } from '@/ee/access-requests/lib/constants' +} from '@/hooks/queries/access-requests' import { useDebounce } from '@/hooks/use-debounce' import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' interface OrganizationAccessRequestsProps { organizationId: string - standalone?: boolean + actions?: SettingsAction[] } export function OrganizationAccessRequests({ organizationId, - standalone = false, + actions, }: OrganizationAccessRequestsProps) { const [params, setParams] = useQueryStates(accessReviewSearchParams, { ...accessRequestUrlOptions, - urlKeys: { 'request-id': standalone ? 'requestId' : 'request-id' }, }) const searchTerm = params['request-search'] const setSearchTerm = useDebouncedSearchSetter((value, options) => @@ -160,7 +159,6 @@ export function OrganizationAccessRequests({ } onClick={() => void setParams({ 'request-id': request.id }, { history: 'push' })} clickLabel={`Review ${request.targetLabel} request from ${request.requester.name || request.requester.email}`} - navigable /> ))}
@@ -201,20 +199,9 @@ export function OrganizationAccessRequests({ maxLength: ACCESS_REQUEST_MAX_SEARCH_LENGTH, } - return standalone ? ( -
- search.onChange(event.target.value)} - placeholder={search.placeholder} - maxLength={search.maxLength} - aria-label='Search requests' - autoComplete='off' - /> + return ( + {content} -
- ) : ( - {content} + ) } diff --git a/apps/sim/ee/access-requests/components/permission-access-boundary.test.tsx b/apps/sim/ee/access-requests/components/permission-access-boundary.test.tsx index a48b07d88a6..bf36909da9c 100644 --- a/apps/sim/ee/access-requests/components/permission-access-boundary.test.tsx +++ b/apps/sim/ee/access-requests/components/permission-access-boundary.test.tsx @@ -34,7 +34,7 @@ vi.mock('@sim/emcn/icons', () => ({ BookOpen: () => null, })) vi.mock('@/ee/access-control/hooks/permission-groups', () => ({ useUserPermissionConfig: policy })) -vi.mock('@/ee/access-requests/hooks/access-requests', () => ({ +vi.mock('@/hooks/queries/access-requests', () => ({ useDiscoverAccessRequests: discovery, })) vi.mock('@/ee/access-requests/components/request-access-action', () => ({ diff --git a/apps/sim/ee/access-requests/components/permission-access-boundary.tsx b/apps/sim/ee/access-requests/components/permission-access-boundary.tsx index 77d0f78487b..a3067e64f88 100644 --- a/apps/sim/ee/access-requests/components/permission-access-boundary.tsx +++ b/apps/sim/ee/access-requests/components/permission-access-boundary.tsx @@ -10,8 +10,8 @@ import { KnowledgeEmptyState } from '@/app/workspace/[workspaceId]/components/re import { TablesEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/tables-empty-state' import { useUserPermissionConfig } from '@/ee/access-control/hooks/permission-groups' import { RequestAccessAction } from '@/ee/access-requests/components/request-access-action' -import { workspaceFeatureDiscoveryQuery } from '@/ee/access-requests/hooks/access-request-keys' -import { useDiscoverAccessRequests } from '@/ee/access-requests/hooks/access-requests' +import { useDiscoverAccessRequests } from '@/hooks/queries/access-requests' +import { workspaceFeatureDiscoveryQuery } from '@/hooks/queries/utils/access-request-keys' /** Safe feature metadata shared by navigation and access-required pages. */ export function useWorkspaceAccessRequestFeatures() { diff --git a/apps/sim/ee/access-requests/components/request-access-action.test.tsx b/apps/sim/ee/access-requests/components/request-access-action.test.tsx index 7a13b40ac72..ad121e6870c 100644 --- a/apps/sim/ee/access-requests/components/request-access-action.test.tsx +++ b/apps/sim/ee/access-requests/components/request-access-action.test.tsx @@ -17,7 +17,7 @@ vi.mock('next/navigation', () => ({ useRouter: () => ({ push: mocks.push }), usePathname: () => '/workspace/workspace', })) -vi.mock('@/ee/access-requests/hooks/access-requests', () => ({ +vi.mock('@/hooks/queries/access-requests', () => ({ useCreateAccessRequest: () => ({ mutate: mocks.create, isPending: false, error: null }), useDiscoverAccessRequests: mocks.discovery, })) @@ -118,7 +118,7 @@ describe('request form lifecycle', () => { expect(document.querySelector('textarea')).toBeNull() clickDialogButton('View request') expect(mocks.push).toHaveBeenCalledWith( - '/workspace/workspace/access-requests?requestId=pending%2Frequest' + '/workspace/workspace/settings/requests?view=requests&requestId=pending%2Frequest' ) expect(mocks.create).not.toHaveBeenCalled() expect(document.querySelector('[role="dialog"]')).toBeNull() @@ -149,7 +149,7 @@ describe('request form lifecycle', () => { }) clickDialogButton('View request') expect(mocks.push).toHaveBeenCalledWith( - '/access-requests?requestId=limit-request&organizationId=organization' + '/access-requests?view=requests&requestId=limit-request&organizationId=organization' ) expect(mocks.create).not.toHaveBeenCalled() }) diff --git a/apps/sim/ee/access-requests/components/request-access-action.tsx b/apps/sim/ee/access-requests/components/request-access-action.tsx index 49f5641ce3d..8de1715cf91 100644 --- a/apps/sim/ee/access-requests/components/request-access-action.tsx +++ b/apps/sim/ee/access-requests/components/request-access-action.tsx @@ -14,14 +14,11 @@ import { type ChipProps, toast, } from '@sim/emcn' -import { Lock } from '@sim/emcn/icons' import { useRouter } from 'next/navigation' import type { AccessRequestScope, AccessRequestTarget } from '@/lib/api/contracts/access-requests' -import { - useCreateAccessRequest, - useDiscoverAccessRequests, -} from '@/ee/access-requests/hooks/access-requests' +import { getMyAccessRequestHref } from '@/ee/access-requests/lib/navigation' import { getAccessRequestTargetKey } from '@/ee/access-requests/lib/targets' +import { useCreateAccessRequest, useDiscoverAccessRequests } from '@/hooks/queries/access-requests' interface RequestAccessActionProps { scope: AccessRequestScope @@ -32,16 +29,6 @@ interface RequestAccessActionProps { variant?: ChipProps['variant'] } -function accessRequestHref(scope: AccessRequestScope, requestId: string): string { - const params = new URLSearchParams({ requestId }) - if (scope.kind === 'organization') params.set('organizationId', scope.organizationId) - const pathname = - scope.kind === 'workspace' - ? `/workspace/${encodeURIComponent(scope.workspaceId)}/access-requests` - : '/access-requests' - return `${pathname}?${params}` -} - export function RequestAccessAction({ scope, target, @@ -55,7 +42,6 @@ export function RequestAccessAction({ return ( onViewRequest(pendingRequestId)} aria-label={`View request for ${label}`} > @@ -66,8 +52,7 @@ export function RequestAccessAction({ return ( View request @@ -100,7 +85,6 @@ function RequestableAccessAction({ <> setOpen(true)} aria-label={ target.kind === 'usage_limit' @@ -225,7 +209,7 @@ export function RequestAccessModal({ : pendingRequestId ? () => { if (onViewRequest) onViewRequest(pendingRequestId) - else router.push(accessRequestHref(scope, pendingRequestId)) + else router.push(getMyAccessRequestHref(scope, pendingRequestId)) onClose() } : submit, diff --git a/apps/sim/ee/access-requests/components/search-params.ts b/apps/sim/ee/access-requests/components/search-params.ts index efe8d9cdb99..67187346d03 100644 --- a/apps/sim/ee/access-requests/components/search-params.ts +++ b/apps/sim/ee/access-requests/components/search-params.ts @@ -23,6 +23,7 @@ const accessRequestSearchParser = createParser({ serialize: String, }).withDefault('') +/** Missing IDs mean no request selection or organization context, so there is no default. */ const accessRequestIdParser = createParser({ parse: (value) => value.length > 0 && value.length <= ACCESS_REQUEST_MAX_ID_LENGTH ? value : null, @@ -36,6 +37,13 @@ export const accessRequestSearchParams = { page: accessRequestPageParser, } as const +/** An omitted view preserves the review landing page for administrators. */ +export const accessRequestSettingsSearchParams = { + ...accessRequestSearchParams, + view: parseAsStringLiteral(['requests', 'catalog', 'review'] as const), + 'request-id': accessRequestIdParser, +} as const + export const accessReviewSearchParams = { 'access-view': parseAsStringLiteral(['groups', 'requests'] as const).withDefault('groups'), 'request-id': accessRequestIdParser, @@ -56,7 +64,10 @@ export const accessRequestUrlOptions = { history: 'replace', clearOnDefault: tru export const accessRequestEntrySearchParams = { ...accessRequestSearchParams, organizationId: accessRequestIdParser, - view: parseAsStringLiteral(['requests', 'catalog', 'admin'] as const).withDefault('requests'), + view: parseAsStringLiteral(['requests', 'catalog', 'review', 'admin'] as const).withDefault( + 'requests' + ), + 'request-id': accessReviewSearchParams['request-id'], 'request-page': accessReviewSearchParams['request-page'], 'request-search': accessReviewSearchParams['request-search'], 'request-status': accessReviewSearchParams['request-status'], diff --git a/apps/sim/ee/access-requests/lib/application/flow.postgres.test.ts b/apps/sim/ee/access-requests/lib/application/flow.postgres.test.ts new file mode 100644 index 00000000000..ceb0f11e0f5 --- /dev/null +++ b/apps/sim/ee/access-requests/lib/application/flow.postgres.test.ts @@ -0,0 +1,602 @@ +/** @vitest-environment node */ + +import { AuditAction, recordAudit } from '@sim/audit' +import type { SessionPrincipal } from '@sim/auth/principal' +import * as schema from '@sim/db/schema' +import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing' +import { generateId } from '@sim/utils/id' +import { eq } from 'drizzle-orm' +import { drizzle } from 'drizzle-orm/postgres-js' +import postgres from 'postgres' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { databaseUrl, select, transaction } = vi.hoisted(() => { + const databaseUrl = process.env.ACCESS_REQUESTS_TEST_DATABASE_URL + if (databaseUrl) { + const url = new URL(databaseUrl) + if ( + !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname) || + url.pathname !== '/sim_access_requests_test' + ) + throw new Error('Use a disposable local sim_access_requests_test database') + } + return { databaseUrl, select: vi.fn(), transaction: vi.fn() } +}) +vi.unmock('drizzle-orm') +vi.unmock('@sim/db/schema') +vi.mock('@sim/db', async () => ({ + ...(await import('@sim/db/schema')), + db: { select, transaction }, + dbReplica: { select }, +})) +vi.mock('@sim/audit', async (original) => ({ + ...(await original()), + recordAudit: vi.fn(), +})) + +import { authorizeWorkspaceOperation } from '@/lib/core/application/workspace-authorization' +import { resolveVerifiedUserAccessControlContext } from '@/lib/permission-groups/resolve.server' +import { tableOperations } from '@/lib/table/application/operations' +import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' +import { + cancelAccessRequest, + createAccessRequest, + discoverAccessRequests, + listMyAccessRequests, + listOrganizationAccessRequests, + updateAccessRequestSettings, +} from '@/ee/access-requests/lib/application/requests' +import { + previewAccessRequest, + resolveAccessRequest, +} from '@/ee/access-requests/lib/application/review' +import { + PERMISSION_ACCESS_REQUEST_CREATED_EVENT, + PERMISSION_ACCESS_REQUEST_DECIDED_EVENT, +} from '@/ee/access-requests/lib/notification-events' + +const schemaName = `access_flow_${generateId().replaceAll('-', '')}` +const connection = databaseUrl + ? postgres(databaseUrl, { + max: 3, + prepare: false, + connection: { + search_path: schemaName, + application_name: schemaName, + statement_timeout: 5000, + }, + onnotice: () => undefined, + }) + : undefined +const database = connection ? drizzle(connection, { schema }) : undefined +const session = (userId: string): SessionPrincipal => ({ + kind: 'session', + userId, + sessionId: `fixture-${userId}`, +}) +const member = session('member') +const admin = session('admin') +const scope = { kind: 'workspace', workspaceId: 'primary' } as const +const target = { kind: 'feature', configKey: 'hideTablesTab' } as const +const page = { limit: 10, offset: 0 } + +beforeAll(async () => { + if (!connection) return + await connection.unsafe(`CREATE SCHEMA "${schemaName}"`) + await connection.unsafe(` + CREATE TABLE "user" ( + id text PRIMARY KEY, name text NOT NULL, email text NOT NULL, + banned boolean DEFAULT false, ban_expires timestamp, suspended_at timestamp + ); + CREATE TABLE organization (id text PRIMARY KEY); + CREATE TABLE member ( + id text PRIMARY KEY, organization_id text REFERENCES organization(id), + user_id text REFERENCES "user"(id), role text NOT NULL + ); + CREATE TABLE user_stats ( + user_id text PRIMARY KEY, billing_blocked boolean DEFAULT false, billing_blocked_reason text + ); + CREATE TABLE subscription ( + id text PRIMARY KEY, plan text NOT NULL, reference_id text NOT NULL, + stripe_customer_id text, stripe_subscription_id text, status text, + period_start timestamp, period_end timestamp, cancel_at_period_end boolean, + cancel_at timestamp, canceled_at timestamp, ended_at timestamp, seats integer, + trial_start timestamp, trial_end timestamp, billing_interval text, + stripe_schedule_id text, metadata json, last_closed_period_start timestamp + ); + CREATE TABLE workspace ( + id text PRIMARY KEY, name text NOT NULL, owner_id text REFERENCES "user"(id), + organization_id text REFERENCES organization(id), workspace_mode text, + billed_account_user_id text, allow_personal_api_keys boolean DEFAULT true, + archived_at timestamp + ); + CREATE TABLE permissions ( + id text PRIMARY KEY, user_id text REFERENCES "user"(id), entity_type text, + entity_id text, permission_type text, updated_at timestamp DEFAULT now(), + UNIQUE(user_id, entity_type, entity_id) + ); + CREATE TABLE permission_group ( + id text PRIMARY KEY, organization_id text REFERENCES organization(id), name text, + description text, config jsonb NOT NULL DEFAULT '{}', created_by text, + created_at timestamp DEFAULT now(), updated_at timestamp DEFAULT now(), + is_default boolean DEFAULT false, membership_mode text DEFAULT 'inherit' + ); + CREATE TABLE permission_group_workspace ( + id text PRIMARY KEY, permission_group_id text REFERENCES permission_group(id), + workspace_id text REFERENCES workspace(id), organization_id text REFERENCES organization(id), + created_at timestamp DEFAULT now() + ); + CREATE TABLE permission_group_member ( + id text PRIMARY KEY, permission_group_id text REFERENCES permission_group(id), + organization_id text REFERENCES organization(id), user_id text REFERENCES "user"(id), + assigned_by text, assigned_at timestamp DEFAULT now() + ); + CREATE TABLE organization_member_usage_limit ( + id text PRIMARY KEY, organization_id text, user_id text, usage_limit numeric, + set_by text, created_at timestamp DEFAULT now(), updated_at timestamp DEFAULT now(), + UNIQUE(organization_id, user_id) + ); + CREATE TABLE organization_access_request_settings ( + organization_id text PRIMARY KEY REFERENCES organization(id), + allow_requests boolean DEFAULT true, updated_at timestamp DEFAULT now(), updated_by text + ); + CREATE TABLE permission_access_request ( + id text PRIMARY KEY, organization_id text NOT NULL REFERENCES organization(id), + requester_id text NOT NULL REFERENCES "user"(id), workspace_id text, + scope_key text NOT NULL, target_key text NOT NULL, target jsonb NOT NULL, + target_label text NOT NULL, membership_id text NOT NULL, group_id text, group_name text, + reason text NOT NULL DEFAULT '', status text NOT NULL DEFAULT 'pending', + decision_reason text, decided_by text REFERENCES "user"(id), decision jsonb, + created_at timestamp NOT NULL DEFAULT now(), updated_at timestamp NOT NULL DEFAULT now(), + decided_at timestamp, + CHECK (status IN ('pending', 'fulfilled', 'declined', 'cancelled', 'closed')) + ); + CREATE UNIQUE INDEX pending_request_unique ON permission_access_request + (organization_id, requester_id, scope_key, target_key) WHERE status = 'pending'; + CREATE TABLE outbox_event ( + id text PRIMARY KEY, event_type text NOT NULL, payload json NOT NULL, + status text DEFAULT 'pending', attempts integer DEFAULT 0, max_attempts integer DEFAULT 10, + available_at timestamp DEFAULT now(), locked_at timestamp, last_error text, + created_at timestamp DEFAULT now(), processed_at timestamp + ); + `) + select.mockImplementation((fields) => database!.select(fields)) + transaction.mockImplementation((callback) => database!.transaction(callback)) +}) + +beforeEach(async () => { + if (!connection) return + vi.mocked(recordAudit).mockClear() + setEnvFlags({ isHosted: true, isBillingEnabled: true, isAccessControlEnabled: true }) + await connection.unsafe(` + TRUNCATE "user", organization, member, user_stats, subscription, workspace, permissions, + permission_group, permission_group_workspace, permission_group_member, + organization_member_usage_limit, organization_access_request_settings, + permission_access_request, outbox_event; + INSERT INTO "user" (id, name, email) VALUES + ('admin', 'Admin', 'admin@example.test'), ('member', 'Member', 'member@example.test'), + ('peer', 'Peer', 'peer@example.test'), ('external', 'External', 'external@example.test'), + ('outsider', 'Outsider', 'outsider@example.test'); + INSERT INTO organization VALUES ('org'), ('other'); + INSERT INTO member VALUES + ('admin-membership', 'org', 'admin', 'owner'), + ('member-membership', 'org', 'member', 'member'), + ('peer-membership', 'org', 'peer', 'member'), + ('other-owner', 'other', 'outsider', 'owner'), + ('external-membership', 'other', 'external', 'member'); + INSERT INTO user_stats (user_id) VALUES ('admin'), ('outsider'); + INSERT INTO subscription (id, plan, reference_id, status, metadata) VALUES + ('org-subscription', 'enterprise', 'org', 'active', '{}'), + ('other-subscription', 'enterprise', 'other', 'active', '{}'); + INSERT INTO workspace (id, name, owner_id, organization_id, workspace_mode, billed_account_user_id) VALUES + ('primary', 'Primary', 'admin', 'org', 'organization', 'admin'), + ('secondary', 'Secondary', 'admin', 'org', 'organization', 'admin'), + ('foreign', 'Foreign', 'outsider', 'other', 'organization', 'outsider'); + INSERT INTO permissions (id, user_id, entity_type, entity_id, permission_type) VALUES + ('member-primary', 'member', 'workspace', 'primary', 'write'), + ('member-secondary', 'member', 'workspace', 'secondary', 'write'), + ('peer-primary', 'peer', 'workspace', 'primary', 'write'), + ('external-primary', 'external', 'workspace', 'primary', 'write'); + INSERT INTO permission_group (id, organization_id, name, config, created_by, is_default) VALUES + ('default', 'org', 'Default', '{"hideTablesTab":true}', 'admin', true), + ('restricted', 'org', 'Restricted', '{"hideTablesTab":true,"hideFilesTab":true}', 'admin', false); + INSERT INTO permission_group_workspace (id, permission_group_id, workspace_id, organization_id) + VALUES ('group-primary', 'restricted', 'primary', 'org'); + `) +}) + +afterAll(async () => { + resetEnvFlagsMock() + if (!connection) return + try { + await connection.unsafe(`DROP SCHEMA "${schemaName}" CASCADE`) + } finally { + await connection.end() + } +}) + +function create(principal = member) { + return createAccessRequest.execute({ + principal, + input: { scope, target, reason: 'Need tables for the project' }, + }) +} + +function preview(requestId: string) { + return previewAccessRequest.execute({ + principal: admin, + input: { organizationId: 'org', requestId }, + }) +} + +function apply(requestId: string, expectedFingerprint: string) { + return resolveAccessRequest.execute({ + principal: admin, + input: { organizationId: 'org', requestId, decision: { action: 'apply', expectedFingerprint } }, + }) +} + +async function authorizeTables(principal = member, workspaceId = 'primary') { + const workspace = await getWorkspaceWithOwner(workspaceId, { executor: database! }) + if (!workspace) throw new Error('Missing fixture workspace') + return authorizeWorkspaceOperation( + principal, + tableOperations.list, + { + workspaceId: workspace.id, + workspaceOrganizationId: workspace.organizationId, + allowPersonalApiKeys: workspace.allowPersonalApiKeys, + }, + { executor: database! } + ) +} + +async function storedState() { + const [group] = await database! + .select({ config: schema.permissionGroup.config }) + .from(schema.permissionGroup) + .where(eq(schema.permissionGroup.id, 'restricted')) + return { + config: group.config, + requests: await connection!` + SELECT id, status, decision, decision_reason, decided_by, decided_at, updated_at + FROM permission_access_request ORDER BY id + `, + memberLimits: await connection!` + SELECT organization_id, user_id, usage_limit, set_by, updated_at + FROM organization_member_usage_limit ORDER BY organization_id, user_id + `, + events: await connection!`SELECT event_type FROM outbox_event ORDER BY event_type`, + audit: vi.mocked(recordAudit).mock.calls.map(([entry]) => ({ + action: entry.action, + actorId: entry.actorId, + workspaceId: entry.workspaceId, + resourceType: entry.resourceType, + resourceId: entry.resourceId, + metadata: structuredClone(entry.metadata), + })), + } +} + +describe.skipIf(!databaseUrl)('access request member-to-admin flow on PostgreSQL', () => { + it('refuses workspace API keys before any protected read or transaction', async () => { + const readsBefore = select.mock.calls.length + const transactionsBefore = transaction.mock.calls.length + await expect( + createAccessRequest.execute({ + principal: { kind: 'workspace_api_key', workspaceId: 'primary', keyId: 'fixture-key' }, + input: { scope, target }, + }) + ).rejects.toMatchObject({ detailCode: 'WORKSPACE_KEY_OPERATION_NOT_PERMITTED' }) + expect(select.mock.calls).toHaveLength(readsBefore) + expect(transaction.mock.calls).toHaveLength(transactionsBefore) + expect((await storedState()).requests).toEqual([]) + expect(vi.mocked(recordAudit)).not.toHaveBeenCalled() + }) + + it('discovers, deduplicates, previews and applies access through the governing group', async () => { + await expect(authorizeTables()).rejects.toMatchObject({ + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + }) + const discovery = await discoverAccessRequests.execute({ + principal: member, + input: { + ...scope, + targetKind: 'feature', + targetKey: 'feature:hideTablesTab', + state: 'requestable', + limit: 1, + }, + }) + expect(discovery.entries).toMatchObject([ + { target, state: 'requestable', pendingRequestId: null }, + ]) + const created = await create() + expect(created).toMatchObject({ + changed: true, + request: { status: 'pending', requester: { id: 'member' } }, + }) + expect(await create()).toMatchObject({ changed: false, request: { id: created.request.id } }) + const inbox = await listOrganizationAccessRequests.execute({ + principal: admin, + input: { organizationId: 'org', status: 'pending', ...page }, + }) + expect(inbox.requests.map(({ id }) => id)).toEqual([created.request.id]) + const prepared = await preview(created.request.id) + expect(prepared).toMatchObject({ + canApply: true, + resolutionKind: 'permission', + group: { id: 'restricted' }, + impact: { memberCount: 4, workspaceCount: 1, workspaceNames: ['Primary'], truncated: false }, + }) + expect(await apply(created.request.id, prepared.fingerprint)).toMatchObject({ + changed: true, + request: { status: 'fulfilled' }, + }) + await expect(authorizeTables()).resolves.toBeUndefined() + await expect(authorizeTables(session('peer'))).resolves.toBeUndefined() + await expect(authorizeTables(session('external'))).resolves.toBeUndefined() + await expect(authorizeTables(member, 'secondary')).rejects.toMatchObject({ + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + }) + const effective = await resolveVerifiedUserAccessControlContext( + 'member', + 'primary', + 'org', + database! + ) + expect(effective).toMatchObject({ + entitled: true, + permissionGroup: { id: 'restricted' }, + config: { hideTablesTab: false, hideFilesTab: true }, + }) + const history = await listMyAccessRequests.execute({ + principal: member, + input: { scope, ...page }, + }) + expect(history.requests).toMatchObject([{ id: created.request.id, status: 'fulfilled' }]) + const persisted = await storedState() + expect(persisted.events).toEqual([ + { event_type: PERMISSION_ACCESS_REQUEST_CREATED_EVENT }, + { event_type: PERMISSION_ACCESS_REQUEST_DECIDED_EVENT }, + ]) + expect(vi.mocked(recordAudit).mock.calls.map(([entry]) => entry.action)).toEqual([ + AuditAction.PERMISSION_ACCESS_REQUEST_CREATED, + AuditAction.PERMISSION_ACCESS_REQUEST_FULFILLED, + AuditAction.PERMISSION_GROUP_UPDATED, + ]) + expect(await apply(created.request.id, prepared.fingerprint)).toMatchObject({ changed: false }) + expect(await storedState()).toEqual(persisted) + }) + + it('fulfills an organization member credit-cap request without changing another member', async () => { + await connection!` + INSERT INTO organization_member_usage_limit (id, organization_id, user_id, usage_limit, set_by) + VALUES ('member-cap', 'org', 'member', 10, 'admin'), ('peer-cap', 'org', 'peer', 7, 'admin') + ` + const organizationScope = { kind: 'organization', organizationId: 'org' } as const + const { request } = await createAccessRequest.execute({ + principal: member, + input: { + scope: organizationScope, + target: { kind: 'usage_limit', id: 'member' }, + reason: 'Need more credits for the project', + }, + }) + expect(request).toMatchObject({ organizationId: 'org', workspaceId: null, status: 'pending' }) + const prepared = await preview(request.id) + expect(prepared).toMatchObject({ + canApply: true, + resolutionKind: 'usage_limit', + group: null, + currentLimitCredits: 2000, + newLimitCredits: null, + }) + const before = await storedState() + await expect(apply(request.id, prepared.fingerprint)).rejects.toMatchObject({ + code: 'validation', + }) + await expect( + resolveAccessRequest.execute({ + principal: admin, + input: { + organizationId: 'org', + requestId: request.id, + decision: { + action: 'apply', + expectedFingerprint: 'stale-preview', + newLimitCredits: 3001, + }, + }, + }) + ).rejects.toMatchObject({ code: 'conflict' }) + expect(await storedState()).toEqual(before) + expect( + await resolveAccessRequest.execute({ + principal: admin, + input: { + organizationId: 'org', + requestId: request.id, + decision: { + action: 'apply', + expectedFingerprint: prepared.fingerprint, + newLimitCredits: 3001, + }, + }, + }) + ).toMatchObject({ changed: true, request: { id: request.id, status: 'fulfilled' } }) + const after = await storedState() + expect(after.memberLimits).toMatchObject([ + { organization_id: 'org', user_id: 'member', usage_limit: '15.005', set_by: 'admin' }, + { organization_id: 'org', user_id: 'peer', usage_limit: '7', set_by: 'admin' }, + ]) + expect(after.memberLimits[1]).toEqual(before.memberLimits[1]) + expect(after.config).toEqual(before.config) + expect(after.audit.map(({ action }) => action)).toEqual([ + AuditAction.PERMISSION_ACCESS_REQUEST_CREATED, + AuditAction.PERMISSION_ACCESS_REQUEST_FULFILLED, + AuditAction.ORG_MEMBER_USAGE_LIMIT_CHANGED, + ]) + const history = await listMyAccessRequests.execute({ + principal: member, + input: { scope: organizationScope, ...page }, + }) + expect(history.requests).toMatchObject([ + { + id: request.id, + target: request.target, + reason: request.reason, + createdAt: request.createdAt, + status: 'fulfilled', + workspaceId: null, + }, + ]) + expect(await preview(request.id)).toMatchObject({ + canApply: false, + resolutionKind: 'usage_limit', + currentLimitCredits: 2000, + newLimitCredits: 3001, + fingerprint: prepared.fingerprint, + }) + }) + + it.each(['decline', 'cancel'] as const)( + 'keeps the policy denied after %s and makes the decision idempotent', + async (action) => { + const { request } = await create() + const decide = () => + action === 'cancel' + ? cancelAccessRequest.execute({ + principal: member, + input: { scope, requestId: request.id }, + }) + : resolveAccessRequest.execute({ + principal: admin, + input: { + organizationId: 'org', + requestId: request.id, + decision: { action: 'decline', reason: 'Not needed for this role' }, + }, + }) + expect(await decide()).toMatchObject({ + changed: true, + request: { status: action === 'cancel' ? 'cancelled' : 'declined' }, + }) + const persisted = await storedState() + expect(persisted.config).toMatchObject({ hideTablesTab: true, hideFilesTab: true }) + expect(persisted.events).toHaveLength(2) + expect(await decide()).toMatchObject({ changed: false }) + expect(await storedState()).toEqual(persisted) + await expect(authorizeTables()).rejects.toMatchObject({ + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + }) + } + ) + + it.each(['policy', 'audience'] as const)( + 'rejects a stale preview after %s changes without partial writes', + async (change) => { + const { request } = await create() + const prepared = await preview(request.id) + if (change === 'policy') { + await connection!`UPDATE permission_group SET config = config || '{"disableTableExport":true}'::jsonb WHERE id = 'restricted'` + } else { + await connection!`DELETE FROM permissions WHERE id = 'peer-primary'` + } + const persisted = await storedState() + await expect(apply(request.id, prepared.fingerprint)).rejects.toMatchObject({ + code: 'conflict', + }) + expect(await storedState()).toEqual(persisted) + const refreshed = await preview(request.id) + expect(refreshed.fingerprint).not.toBe(prepared.fingerprint) + expect(await apply(request.id, refreshed.fingerprint)).toMatchObject({ + request: { status: 'fulfilled' }, + }) + } + ) + + it('enforces admin, requester and organization scope against real memberships', async () => { + const { request } = await create() + const persisted = await storedState() + await expect( + previewAccessRequest.execute({ + principal: member, + input: { organizationId: 'org', requestId: request.id }, + }) + ).rejects.toMatchObject({ detailCode: 'ORGANIZATION_ADMIN_REQUIRED' }) + await expect( + resolveAccessRequest.execute({ + principal: member, + input: { + organizationId: 'org', + requestId: request.id, + decision: { action: 'decline', reason: 'No' }, + }, + }) + ).rejects.toMatchObject({ detailCode: 'ORGANIZATION_ADMIN_REQUIRED' }) + await expect( + cancelAccessRequest.execute({ + principal: session('peer'), + input: { scope, requestId: request.id }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + await expect( + cancelAccessRequest.execute({ + principal: member, + input: { scope: { kind: 'workspace', workspaceId: 'secondary' }, requestId: request.id }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + await expect( + previewAccessRequest.execute({ + principal: session('outsider'), + input: { organizationId: 'other', requestId: request.id }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + await expect(create(session('outsider'))).rejects.toMatchObject({ code: 'not_found' }) + expect(await storedState()).toEqual(persisted) + }) + + it('allows workspace collaborators to request access without granting organization authority', async () => { + const { request } = await create(session('external')) + expect((await preview(request.id)).canApply).toBe(true) + await expect( + listMyAccessRequests.execute({ + principal: session('external'), + input: { scope: { kind: 'organization', organizationId: 'org' }, ...page }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + await connection!`DELETE FROM permissions WHERE id = 'external-primary'` + const prepared = await preview(request.id) + expect(prepared.canApply).toBe(false) + await expect(apply(request.id, prepared.fingerprint)).rejects.toMatchObject({ + code: 'conflict', + }) + expect((await storedState()).requests).toMatchObject([{ id: request.id, status: 'pending' }]) + }) + + it('preserves history and cancellation while requests are paused', async () => { + const { request } = await create() + await updateAccessRequestSettings.execute({ + principal: admin, + input: { organizationId: 'org', allowRequests: false }, + }) + const discovery = await discoverAccessRequests.execute({ + principal: member, + input: { ...scope, targetKind: 'feature', limit: 1 }, + }) + expect(discovery).toMatchObject({ enabled: false, entries: [] }) + await expect(create()).rejects.toMatchObject({ detailCode: 'ACCESS_REQUESTS_DISABLED' }) + expect( + (await listMyAccessRequests.execute({ principal: member, input: { scope, ...page } })) + .requests + ).toMatchObject([{ id: request.id, status: 'pending' }]) + expect((await preview(request.id)).canApply).toBe(false) + expect( + await cancelAccessRequest.execute({ + principal: member, + input: { scope, requestId: request.id }, + }) + ).toMatchObject({ changed: true, request: { status: 'cancelled' } }) + }) +}) diff --git a/apps/sim/ee/access-requests/lib/navigation.test.ts b/apps/sim/ee/access-requests/lib/navigation.test.ts index 46ace5416a7..083266c0fbc 100644 --- a/apps/sim/ee/access-requests/lib/navigation.test.ts +++ b/apps/sim/ee/access-requests/lib/navigation.test.ts @@ -1,6 +1,88 @@ /** @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { getLegacyAccessRequestsQuery } from '@/ee/access-requests/lib/navigation' +import { + getAccessRequestsSettingsHref, + getLegacyAccessRequestsQuery, + getLegacyAccessRequestsSettingsQuery, + getMyAccessRequestHref, +} from '@/ee/access-requests/lib/navigation' + +describe('requests settings navigation', () => { + it('keeps workspace links in workspace settings and organization links in the resolver', () => { + expect(getAccessRequestsSettingsHref({ kind: 'workspace', workspaceId: 'workspace/a' })).toBe( + '/workspace/workspace%2Fa/settings/requests' + ) + expect(getAccessRequestsSettingsHref({ kind: 'organization', organizationId: 'org-a' })).toBe( + '/o/org-a/settings/requests' + ) + expect( + getMyAccessRequestHref({ kind: 'workspace', workspaceId: 'workspace/a' }, 'request/a') + ).toBe('/workspace/workspace%2Fa/settings/requests?view=requests&requestId=request%2Fa') + expect( + getMyAccessRequestHref({ kind: 'organization', organizationId: 'org/a' }, 'request/a') + ).toBe('/access-requests?view=requests&requestId=request%2Fa&organizationId=org%2Fa') + }) + + it('preserves requester selection, search and pagination with an explicit requester view', () => { + const query = new URLSearchParams( + getLegacyAccessRequestsSettingsQuery({ + requestId: 'request/a', + search: 'Tables and files', + page: '2', + organizationId: 'org-a', + }) + ) + expect(Object.fromEntries(query)).toEqual({ + view: 'requests', + requestId: 'request/a', + search: 'Tables and files', + page: '2', + }) + }) + + it.each(['admin', 'review'])( + 'preserves %s review links with canonical selection and filters', + (view) => { + const query = new URLSearchParams( + getLegacyAccessRequestsSettingsQuery({ + view, + requestId: 'request/a', + 'request-search': 'Tables', + 'request-page': '2', + 'request-status': 'declined', + }) + ) + expect(Object.fromEntries(query)).toEqual({ + view: 'review', + 'request-id': 'request/a', + 'request-search': 'Tables', + 'request-page': '2', + 'request-status': 'declined', + }) + } + ) + + it('prefers canonical review selection when both selection parameters are present', () => { + const query = new URLSearchParams( + getLegacyAccessRequestsSettingsQuery({ + view: 'review', + requestId: 'old', + 'request-id': 'current', + }) + ) + expect(query.get('request-id')).toBe('current') + expect(query.has('requestId')).toBe(false) + }) + + it('preserves Browse access and parses invalid state through shared parsers', () => { + expect(getLegacyAccessRequestsSettingsQuery({ view: 'catalog', search: 'Tables' })).toBe( + '?view=catalog&search=Tables' + ) + expect( + getLegacyAccessRequestsSettingsQuery({ view: 'invalid', page: '-1', search: 'a'.repeat(201) }) + ).toBe('?view=requests') + }) +}) describe('legacy access request navigation', () => { it('preserves review filters and selection without leaking permission group view state', () => { diff --git a/apps/sim/ee/access-requests/lib/navigation.ts b/apps/sim/ee/access-requests/lib/navigation.ts index cb899b527d0..c3acfd40498 100644 --- a/apps/sim/ee/access-requests/lib/navigation.ts +++ b/apps/sim/ee/access-requests/lib/navigation.ts @@ -1,6 +1,52 @@ import { omit } from '@sim/utils/object' import { createLoader, createSerializer } from 'nuqs/server' -import { accessReviewSearchParams } from '@/ee/access-requests/components/search-params' +import type { AccessRequestScope } from '@/lib/api/contracts/access-requests' +import { organizationRoutes } from '@/lib/navigation/paths' +import { + accessRequestEntrySearchParams, + accessRequestSettingsSearchParams, + accessReviewSearchParams, +} from '@/ee/access-requests/components/search-params' + +const loadEntrySearchParams = createLoader(accessRequestEntrySearchParams) +const serializeSettingsSearchParams = createSerializer({ + ...accessReviewSearchParams, + ...accessRequestSettingsSearchParams, +}) + +export function getAccessRequestsSettingsHref(scope: AccessRequestScope): string { + return scope.kind === 'workspace' + ? `/workspace/${encodeURIComponent(scope.workspaceId)}/settings/requests` + : organizationRoutes(scope.organizationId).settingsSection('requests') +} + +/** Organization links use the entry resolver so they also work outside the Search rollout. */ +export function getMyAccessRequestHref(scope: AccessRequestScope, requestId: string): string { + const pathname = + scope.kind === 'workspace' ? getAccessRequestsSettingsHref(scope) : '/access-requests' + const query = serializeSettingsSearchParams({ view: 'requests', requestId }) + const params = new URLSearchParams(query) + if (scope.kind === 'organization') params.set('organizationId', scope.organizationId) + return `${pathname}?${params}` +} + +/** Moves requester and email links into settings without changing their selected view or scope. */ +export function getLegacyAccessRequestsSettingsQuery( + searchParams: Record +): string { + const params = loadEntrySearchParams(searchParams) + const review = params.view === 'admin' || params.view === 'review' + return serializeSettingsSearchParams({ + view: review ? 'review' : params.view === 'catalog' ? 'catalog' : 'requests', + search: params.search, + page: params.page, + requestId: review ? null : params.requestId, + 'request-id': review ? (params['request-id'] ?? params.requestId) : null, + 'request-search': params['request-search'], + 'request-page': params['request-page'], + 'request-status': params['request-status'], + }) +} const loadReviewSearchParams = createLoader(accessReviewSearchParams) const serializeReviewSearchParams = createSerializer( diff --git a/apps/sim/ee/access-requests/lib/notifications.test.ts b/apps/sim/ee/access-requests/lib/notifications.test.ts index 2ecee67538f..359bf588f27 100644 --- a/apps/sim/ee/access-requests/lib/notifications.test.ts +++ b/apps/sim/ee/access-requests/lib/notifications.test.ts @@ -218,7 +218,7 @@ describe('access request administrator notifications', () => { expect(mockRender).toHaveBeenCalledExactlyOnceWith({ kind: 'created', requestLink: - 'https://sim.example/access-requests?organizationId=organization-one&view=admin&requestId=request-one', + 'https://sim.example/access-requests?organizationId=organization-one&view=review&request-id=request-one', }) expect(mockSend).toHaveBeenCalledWith( expect.objectContaining({ to: 'current-admin@example.com', emailType: 'transactional' }) @@ -270,7 +270,7 @@ describe('access request requester notifications', () => { expect(mockRender).toHaveBeenCalledExactlyOnceWith({ kind: 'decided', requestLink: - 'https://sim.example/workspace/workspace-one/access-requests?requestId=request-one', + 'https://sim.example/workspace/workspace-one/settings/requests?view=requests&requestId=request-one', }) expect(mockSend).toHaveBeenCalledWith( expect.objectContaining({ to: 'requester@example.com', emailType: 'transactional' }) @@ -294,7 +294,7 @@ describe('access request requester notifications', () => { expect(mockRender).toHaveBeenCalledExactlyOnceWith({ kind: 'decided', requestLink: - 'https://sim.example/workspace/workspace-one/access-requests?requestId=request-one', + 'https://sim.example/workspace/workspace-one/settings/requests?view=requests&requestId=request-one', }) }) @@ -307,7 +307,7 @@ describe('access request requester notifications', () => { expect(mockRender).toHaveBeenCalledExactlyOnceWith({ kind: 'decided', requestLink: - 'https://sim.example/workspace/workspace%2Fwith%3Fcharacters/access-requests?requestId=request-one', + 'https://sim.example/workspace/workspace%2Fwith%3Fcharacters/settings/requests?view=requests&requestId=request-one', }) }) @@ -350,7 +350,7 @@ describe('access request requester notifications', () => { expect(mockRender).toHaveBeenCalledWith({ kind: 'decided', requestLink: - 'https://sim.example/access-requests?organizationId=organization-one&requestId=request-one', + 'https://sim.example/access-requests?view=requests&requestId=request-one&organizationId=organization-one', }) }) diff --git a/apps/sim/ee/access-requests/lib/notifications.ts b/apps/sim/ee/access-requests/lib/notifications.ts index 0ef80c6ae7f..28d2bf48012 100644 --- a/apps/sim/ee/access-requests/lib/notifications.ts +++ b/apps/sim/ee/access-requests/lib/notifications.ts @@ -14,6 +14,7 @@ import { import { getBaseUrl } from '@/lib/core/utils/urls' import { hasEmailService, sendEmail } from '@/lib/messaging/email/mailer' import { loadAccessRequestMembership } from '@/ee/access-requests/lib/application/authorization' +import { getMyAccessRequestHref } from '@/ee/access-requests/lib/navigation' import { PERMISSION_ACCESS_REQUEST_CREATED_EVENT, PERMISSION_ACCESS_REQUEST_DECIDED_EVENT, @@ -70,16 +71,22 @@ async function requesterHasCurrentAccess(request: NotificationRequest): Promise< } function requestLink(request: NotificationRequest, kind: 'created' | 'decided'): string { - const requesterWorkspaceId = kind === 'decided' ? request.workspaceId : null - const url = new URL( - requesterWorkspaceId - ? `/workspace/${encodeURIComponent(requesterWorkspaceId)}/access-requests` - : '/access-requests', - getBaseUrl() - ) - if (!requesterWorkspaceId) url.searchParams.set('organizationId', request.organizationId) - if (kind === 'created') url.searchParams.set('view', 'admin') - url.searchParams.set('requestId', request.id) + if (kind === 'decided') { + return new URL( + getMyAccessRequestHref( + request.workspaceId + ? { kind: 'workspace', workspaceId: request.workspaceId } + : { kind: 'organization', organizationId: request.organizationId }, + request.id + ), + getBaseUrl() + ).toString() + } + + const url = new URL('/access-requests', getBaseUrl()) + url.searchParams.set('organizationId', request.organizationId) + url.searchParams.set('view', 'review') + url.searchParams.set('request-id', request.id) return url.toString() } diff --git a/apps/sim/ee/organization-usage/components/usage-consumers.tsx b/apps/sim/ee/organization-usage/components/usage-consumers.tsx index a4b9342bdba..8a508eaf2c9 100644 --- a/apps/sim/ee/organization-usage/components/usage-consumers.tsx +++ b/apps/sim/ee/organization-usage/components/usage-consumers.tsx @@ -23,6 +23,7 @@ import { OpenRouterIcon, SakanaIcon, TogetherIcon, + TypeSafeIcon, VertexIcon, VllmIcon, xAIIcon, @@ -74,6 +75,7 @@ const PROVIDER_ICONS: Readonly { resetDbChainMock() }) + describe('native evaluation models', () => { + const questions = { passed: { type: 'noul', instructions: 'Did the task succeed?' } } + const inputs: AgentInputs = { + model: 'jev-1.13.0', + apiKey: 'test-key', + evaluationState: 'Task complete', + evaluationQuestions: questions, + } + + it('uses the provider path with native inputs and exposes structured answers', async () => { + mockGetProviderFromModel.mockReturnValue('typesafe') + const answers = { passed: { type: 'noul', noul: 0.98 } } + mockExecuteProviderRequest.mockResolvedValue({ + content: JSON.stringify(answers), + answers, + model: inputs.model, + tokens: { input: 20, output: 5, total: 25 }, + }) + const result = await handler.execute(mockContext, mockBlock, inputs) + expect(mockValidateModelProvider).toHaveBeenCalledWith( + mockContext.userId, + mockContext.workspaceId, + inputs.model, + mockContext + ) + expect(mockExecuteProviderRequest).toHaveBeenCalledWith( + 'typesafe', + expect.objectContaining({ + model: inputs.model, + apiKey: 'test-key', + evaluation: { state: 'Task complete', questions }, + context: undefined, + systemPrompt: undefined, + tools: [], + }), + expect.anything() + ) + expect(result).toMatchObject({ + answers, + content: JSON.stringify(answers), + tokens: { total: 25 }, + }) + }) + + it('ignores saved chat settings after switching the model to Jev', async () => { + mockGetProviderFromModel.mockReturnValue('typesafe') + await handler.execute(mockContext, mockBlock, { + ...inputs, + messages: [{ role: 'user', content: 'Old conversation' }], + systemPrompt: 'Old prompt', + tools: [{ type: 'custom-tool', title: 'Stale tool' }], + skills: [{ skillId: 'stale-skill' }], + responseFormat: '{invalid stale JSON', + memoryType: 'conversation', + conversationId: 'stale-conversation', + temperature: 0.5, + maxTokens: 100, + files: [{ name: 'old.png' }], + fallbackModels: [{ model: 'gpt-4o' }], + }) + const request = mockExecuteProviderRequest.mock.calls[0][1] + expect(request).toMatchObject({ + evaluation: { state: 'Task complete', questions }, + tools: [], + context: undefined, + temperature: undefined, + maxTokens: undefined, + }) + expect(request.messages ?? []).toEqual([]) + expect(mockOpenAgentTurnSession).not.toHaveBeenCalled() + expect(mockExecuteProviderRequest).toHaveBeenCalledOnce() + }) + + it('removes saved evaluation inputs when switching back to a chat model', async () => { + await handler.execute(mockContext, mockBlock, { + ...inputs, + model: 'gpt-4o', + messages: [{ role: 'user', content: 'Hello' }], + }) + expect(mockExecuteProviderRequest.mock.calls[0][1].evaluation).toBeUndefined() + }) + + it.each(['evaluationState', 'evaluationQuestions'] as const)( + 'projects secrets in %s before the provider boundary', + async (field) => { + const registry = new ResolvedSecretTraceRegistry([ + { name: 'PRIVATE_TEXT', plaintext: 'private value', encryptedValue: 'encrypted' }, + ]) + const path = field === 'evaluationState' ? [field] : [field, 'passed', 'instructions'] + registry.recordResolvedAtInputPath('PRIVATE_TEXT', 'private value', path) + registry.recordResolvedInputProjection(path, 'private value', '{{PRIVATE_TEXT}}') + mockContext.resolvedSecretTraceRegistry = registry + mockGetProviderFromModel.mockReturnValue('typesafe') + await handler.execute(mockContext, mockBlock, { + ...inputs, + [field]: + field === 'evaluationState' + ? 'private value' + : { passed: { type: 'noul', instructions: 'private value' } }, + }) + const request = mockExecuteProviderRequest.mock.calls[0][1] + expect(JSON.stringify(request.evaluation)).not.toContain('private value') + expect(JSON.stringify(request.evaluation)).toContain('{{PRIVATE_TEXT}}') + expect(request.apiKey).toBe('test-key') + } + ) + + it('refuses an evaluation model as a conversational fallback before executing the primary', async () => { + await expect( + handler.execute(mockContext, mockBlock, { + model: 'gpt-4o', + messages: [{ role: 'user', content: 'Hello' }], + fallbackModels: [{ model: 'jev-latest' }], + }) + ).rejects.toThrow('Evaluation models cannot serve as chat fallbacks') + expect(mockExecuteProviderRequest).not.toHaveBeenCalled() + }) + }) + describe('canHandle', () => { it('should return true for blocks with metadata id "agent"', () => { expect(handler.canHandle(mockBlock)).toBe(true) diff --git a/apps/sim/executor/handlers/agent/agent-handler.ts b/apps/sim/executor/handlers/agent/agent-handler.ts index 1d21c6ffd50..38fa9e1f054 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.ts @@ -120,7 +120,7 @@ import { canUseProviderLargeFilePath, getInlineHydrationMaxBytes, } from '@/providers/file-attachments.server' -import { isAutoModel, SIM_AUTO_MODEL_ID } from '@/providers/models' +import { isAutoModel, isEvaluationModel, SIM_AUTO_MODEL_ID } from '@/providers/models' import { type ProviderToolInputProvenance, registerProviderToolInputProvenance, @@ -321,6 +321,15 @@ export class AgentBlockHandler implements BlockHandler { inputs: AgentInputs, nodeMetadata?: BlockNodeMetadata ): Promise { + /** Inactive fields can remain saved when the builder switches model modalities. */ + inputs = isEvaluationModel(inputs.model || AGENT.DEFAULT_MODEL) + ? { + model: inputs.model, + apiKey: inputs.apiKey, + evaluationState: inputs.evaluationState, + evaluationQuestions: inputs.evaluationQuestions, + } + : inputs ctx.mcpBlockId = block.id const providerErrorRegistry = ctx.resolvedSecretTraceRegistry?.forkForInputPaths( AGENT_RAW_PROVIDER_ERROR_INPUT_PATHS @@ -379,6 +388,10 @@ export class AgentBlockHandler implements BlockHandler { userPrompt: filteredInputs.userPrompt, messages: filteredInputs.messages, memories: filteredInputs.memories, + ...(isEvaluationModel(filteredInputs.model || AGENT.DEFAULT_MODEL) && { + evaluationState: filteredInputs.evaluationState, + evaluationQuestions: filteredInputs.evaluationQuestions, + }), }, coreModelInputPaths ) @@ -552,6 +565,9 @@ export class AgentBlockHandler implements BlockHandler { filteredInputs.fallbackModels, logger ) + if (configuredFallbacks.some((candidate) => isEvaluationModel(candidate.model))) { + throw new Error('Evaluation models cannot serve as chat fallbacks') + } const retry = nodeMetadata?.retry const fallbacksHeld = retry !== undefined && !retry.isFinalTry const fallbackCandidates = @@ -2487,6 +2503,9 @@ export class AgentBlockHandler implements BlockHandler { private getModelInputPaths(inputs: AgentInputs): ResolvedSecretInputPath[] { const paths: ResolvedSecretInputPath[] = [['systemPrompt'], ['userPrompt']] + if (isEvaluationModel(inputs.model || AGENT.DEFAULT_MODEL)) { + paths.push(['evaluationState'], ['evaluationQuestions']) + } for (let index = 0; index < (inputs.messages?.length ?? 0); index++) { const message = inputs.messages?.[index] const messageRoot = ['messages', String(index)] as const @@ -2841,8 +2860,11 @@ export class AgentBlockHandler implements BlockHandler { return { provider: providerId, model, + evaluation: isEvaluationModel(model) + ? { state: inputs.evaluationState, questions: inputs.evaluationQuestions } + : undefined, systemPrompt: validMessages ? undefined : inputs.systemPrompt, - context: validMessages ? undefined : stringifyJSON(messages), + context: validMessages || isEvaluationModel(model) ? undefined : stringifyJSON(messages), tools: formattedTools, temperature: inputs.temperature != null && inputs.temperature !== '' @@ -2944,6 +2966,7 @@ export class AgentBlockHandler implements BlockHandler { providerId, { model, + evaluation: providerRequest.evaluation, systemPrompt: 'systemPrompt' in providerRequest ? providerRequest.systemPrompt : undefined, context: 'context' in providerRequest ? providerRequest.context : undefined, @@ -3273,6 +3296,7 @@ export class AgentBlockHandler implements BlockHandler { private processStandardResponse(result: any): BlockOutput { return { content: result.content, + ...(result.answers && { answers: result.answers }), ...this.createResponseMetadata(result), ...(result.interactionId && { interactionId: result.interactionId }), } diff --git a/apps/sim/executor/handlers/agent/types.ts b/apps/sim/executor/handlers/agent/types.ts index b5ce98a6c70..a092fee6959 100644 --- a/apps/sim/executor/handlers/agent/types.ts +++ b/apps/sim/executor/handlers/agent/types.ts @@ -15,6 +15,8 @@ export interface SkillInput { } export interface AgentInputs { + evaluationState?: unknown + evaluationQuestions?: unknown model?: string responseFormat?: string | object tools?: ToolInput[] diff --git a/apps/sim/ee/access-requests/hooks/access-requests.test.tsx b/apps/sim/hooks/queries/access-requests.test.tsx similarity index 98% rename from apps/sim/ee/access-requests/hooks/access-requests.test.tsx rename to apps/sim/hooks/queries/access-requests.test.tsx index 9ce34d47fc1..21d97bf12b7 100644 --- a/apps/sim/ee/access-requests/hooks/access-requests.test.tsx +++ b/apps/sim/hooks/queries/access-requests.test.tsx @@ -14,12 +14,12 @@ import { listMyAccessRequestsContract, resolveAccessRequestContract, } from '@/lib/api/contracts/access-requests' -import { accessRequestKeys } from '@/ee/access-requests/hooks/access-request-keys' import { useDiscoverAccessRequests, useMyAccessRequests, useResolveAccessRequest, -} from '@/ee/access-requests/hooks/access-requests' +} from '@/hooks/queries/access-requests' +import { accessRequestKeys } from '@/hooks/queries/utils/access-request-keys' import { organizationKeys } from '@/hooks/queries/utils/organization-keys' import { workspaceUsageKeys } from '@/hooks/queries/utils/workspace-usage-keys' diff --git a/apps/sim/ee/access-requests/hooks/access-requests.ts b/apps/sim/hooks/queries/access-requests.ts similarity index 99% rename from apps/sim/ee/access-requests/hooks/access-requests.ts rename to apps/sim/hooks/queries/access-requests.ts index 9b1d31d5c6d..281e0c48df8 100644 --- a/apps/sim/ee/access-requests/hooks/access-requests.ts +++ b/apps/sim/hooks/queries/access-requests.ts @@ -22,11 +22,11 @@ import type { WorkspaceCreditAvailability, WorkspaceUsageGate, } from '@/lib/api/contracts/workspaces' +import { ACCESS_REQUEST_LIST_PAGE_SIZE } from '@/ee/access-requests/lib/constants' import { ACCESS_REQUESTS_STALE_TIME, accessRequestKeys, -} from '@/ee/access-requests/hooks/access-request-keys' -import { ACCESS_REQUEST_LIST_PAGE_SIZE } from '@/ee/access-requests/lib/constants' +} from '@/hooks/queries/utils/access-request-keys' import { invalidateWorkspaceUsage } from '@/hooks/queries/utils/invalidate-usage' import { organizationKeys } from '@/hooks/queries/utils/organization-keys' import { permissionGroupKeys } from '@/hooks/queries/utils/permission-group-keys' diff --git a/apps/sim/hooks/queries/mcp.test.tsx b/apps/sim/hooks/queries/mcp.test.tsx index 167c94e51a3..637a8bbae41 100644 --- a/apps/sim/hooks/queries/mcp.test.tsx +++ b/apps/sim/hooks/queries/mcp.test.tsx @@ -27,6 +27,7 @@ import { useAllowedMcpDomains, useForceRefreshMcpTools, useMcpServers, + useMcpToolServers, useMcpToolsQuery, useStoredMcpTools, } from '@/hooks/queries/mcp' @@ -118,6 +119,75 @@ class FakeEventSource { close(): void {} } +describe('useMcpToolServers', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('lists ordinary workspace servers when no managed connections are available', async () => { + const sharedServer = server('shared-server') + mockServers([ + sharedServer, + server('managed-canonical-server', { credentialGroupId: 'group-1' }), + ]) + + const hook = renderHookWithClient(() => useMcpToolServers(WORKSPACE_ID)) + await flush() + + expect(hook.getResult()).toEqual({ data: [sharedServer], isLoading: false, error: null }) + hook.unmount() + }) + + it('includes allowed managed connections alongside ordinary servers', async () => { + const sharedServer = server('shared-server') + const managedServer = server('mcp-cg-123456789012345678901', { + name: 'Fireflies — person@example.com', + managedConnectorId: 'fireflies', + authType: 'oauth', + url: undefined, + }) + mockRequestJson.mockImplementation(async (contract) => { + if (contract === listMcpServersContract) { + return { success: true, data: { servers: [sharedServer] } } + } + if (contract === listManagedMcpCatalogContract) return { servers: [managedServer], tools: [] } + throw new Error('Unexpected MCP request') + }) + + const hook = renderHookWithClient(() => useMcpToolServers(WORKSPACE_ID)) + await flush() + + expect(hook.getResult()).toEqual({ + data: [sharedServer, managedServer], + isLoading: false, + error: null, + }) + hook.unmount() + }) + + it.each([ + { name: 'shared servers', failingContract: listMcpServersContract }, + { name: 'managed catalog', failingContract: listManagedMcpCatalogContract }, + ])('keeps unrelated $name errors visible', async ({ failingContract }) => { + const error = new Error('MCP request failed') + mockRequestJson.mockImplementation(async (contract) => { + if (contract === failingContract) throw error + if (contract === listMcpServersContract) { + return { success: true, data: { servers: [server('shared-server')] } } + } + if (contract === listManagedMcpCatalogContract) return { servers: [], tools: [] } + throw new Error('Unexpected MCP request') + }) + + const hook = renderHookWithClient(() => useMcpToolServers(WORKSPACE_ID)) + await flush() + + expect(hook.getResult().error).toBe(error) + expect(hook.getResult().isLoading).toBe(false) + hook.unmount() + }) +}) + describe('useMcpToolsQuery', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/ee/access-requests/hooks/access-request-keys.ts b/apps/sim/hooks/queries/utils/access-request-keys.ts similarity index 100% rename from apps/sim/ee/access-requests/hooks/access-request-keys.ts rename to apps/sim/hooks/queries/utils/access-request-keys.ts diff --git a/apps/sim/lib/api-key/byok.test.ts b/apps/sim/lib/api-key/byok.test.ts index 5a905237cf2..3e0e7893afc 100644 --- a/apps/sim/lib/api-key/byok.test.ts +++ b/apps/sim/lib/api-key/byok.test.ts @@ -59,6 +59,7 @@ vi.mock('@/stores/providers/store', () => ({ useProvidersStore: { getState: vi.fn() }, })) +import { byokProviderIdSchema } from '@/lib/api/contracts/byok-keys' import { getApiKeyWithBYOK, getBYOKKey } from '@/lib/api-key/byok' import { useProvidersStore } from '@/stores/providers/store' @@ -544,6 +545,89 @@ describe('getApiKeyWithBYOK provider classification', () => { }) }) +describe('getApiKeyWithBYOK for TypeSafe', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockIsHosted.value = true + mockGetHostedModels.mockReturnValue(['jev-latest', 'jev-1.13.0', 'jev-preview']) + mockGetRotatingApiKey.mockReturnValue('hosted-typesafe-key') + mockDecryptSecret.mockImplementation(async (encrypted: string) => ({ + decrypted: encrypted.replace('encrypted-', 'decrypted-'), + })) + mockIsOrganizationBYOKEntitled.mockResolvedValue(true) + }) + + it('accepts TypeSafe in workspace and organization BYOK contracts', () => { + expect(byokProviderIdSchema.parse('typesafe')).toBe('typesafe') + }) + + it.each(['jev-latest', 'jev-1.13.0', 'jev-preview'])( + 'resolves the platform pool when %s has no BYOK key', + async (model) => { + await expect(getApiKeyWithBYOK('typesafe', model, uniqueWorkspaceId())).resolves.toEqual({ + apiKey: 'hosted-typesafe-key', + isBYOK: false, + }) + expect(mockGetRotatingApiKey).toHaveBeenCalledWith('typesafe') + } + ) + + it('prefers the workspace pool without selecting a hosted key', async () => { + dbChainMockFns.orderBy.mockResolvedValueOnce([storedKey('workspace-key')]) + await expect(getApiKeyWithBYOK('typesafe', 'jev-latest', uniqueWorkspaceId())).resolves.toEqual( + { + apiKey: 'decrypted-workspace-key', + isBYOK: true, + scope: 'workspace', + } + ) + expect(mockGetRotatingApiKey).not.toHaveBeenCalled() + expect(mockIsOrganizationBYOKEntitled).not.toHaveBeenCalled() + }) + + it('inherits an entitled organization pool before using hosted credits', async () => { + dbChainMockFns.orderBy + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([storedOrganizationKey(uniqueOrganizationId(), 'organization-key')]) + await expect(getApiKeyWithBYOK('typesafe', 'jev-latest', uniqueWorkspaceId())).resolves.toEqual( + { + apiKey: 'decrypted-organization-key', + isBYOK: true, + scope: 'organization', + } + ) + expect(mockGetRotatingApiKey).not.toHaveBeenCalled() + }) + + it('rejects missing hosted credentials instead of making an unauthenticated request', async () => { + mockGetRotatingApiKey.mockImplementation(() => { + throw new Error('No configured key') + }) + await expect(getApiKeyWithBYOK('typesafe', 'jev-latest', uniqueWorkspaceId())).rejects.toThrow( + 'No API key available for typesafe jev-latest' + ) + }) + + it('never gives the hosted key to an unlisted model', async () => { + await expect(getApiKeyWithBYOK('typesafe', 'jev-custom', uniqueWorkspaceId())).rejects.toThrow( + 'API key is required' + ) + expect(mockGetRotatingApiKey).not.toHaveBeenCalled() + }) + + it('requires caller credentials on self-hosted deployments', async () => { + mockIsHosted.value = false + await expect( + getApiKeyWithBYOK('typesafe', 'jev-latest', uniqueWorkspaceId(), 'caller-key') + ).resolves.toEqual({ apiKey: 'caller-key', isBYOK: false }) + await expect(getApiKeyWithBYOK('typesafe', 'jev-latest', uniqueWorkspaceId())).rejects.toThrow( + 'API key is required' + ) + expect(mockGetRotatingApiKey).not.toHaveBeenCalled() + }) +}) + describe('getApiKeyWithBYOK for Fireworks', () => { const HOSTED_POOL_MODEL = 'fireworks/glm-5.2' diff --git a/apps/sim/lib/api-key/byok.ts b/apps/sim/lib/api-key/byok.ts index a9ce7d43e08..3a5088f3c6c 100644 --- a/apps/sim/lib/api-key/byok.ts +++ b/apps/sim/lib/api-key/byok.ts @@ -328,6 +328,7 @@ export async function getApiKeyWithBYOK( const isZaiModel = provider === 'zai' const isXaiModel = provider === 'xai' const isKimiModel = provider === 'kimi' + const isTypeSafeModel = provider === 'typesafe' const byokProviderId = isGeminiModel ? 'google' : (provider as BYOKProviderId) @@ -340,7 +341,8 @@ export async function getApiKeyWithBYOK( isMistralModel || isZaiModel || isXaiModel || - isKimiModel) + isKimiModel || + isTypeSafeModel) ) { const hostedModels = getHostedModels() const isModelHosted = hostedModels.some((m) => m.toLowerCase() === model.toLowerCase()) diff --git a/apps/sim/lib/api/contracts/access-requests.ts b/apps/sim/lib/api/contracts/access-requests.ts index 02951251afb..6ba59631b56 100644 --- a/apps/sim/lib/api/contracts/access-requests.ts +++ b/apps/sim/lib/api/contracts/access-requests.ts @@ -1,5 +1,9 @@ import { z } from 'zod' -import { organizationIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { + organizationIdSchema, + withMissingFieldMessage, + workspaceIdSchema, +} from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' import { PERMISSION_GROUP_FIELDS } from '@/lib/permission-groups/fields' import { @@ -147,14 +151,20 @@ export const resolveAccessRequestBodySchema = z.discriminatedUnion('action', [ z .object({ action: z.literal('apply'), - expectedFingerprint: fingerprintSchema, + expectedFingerprint: withMissingFieldMessage( + fingerprintSchema, + 'expectedFingerprint is required; preview the request before applying it' + ), newLimitCredits: usageLimitSchema.optional(), }) .strict(), z .object({ action: z.literal('decline'), - reason: reasonSchema.min(1, 'Explain why this request was declined'), + reason: withMissingFieldMessage( + reasonSchema.min(1, 'Explain why this request was declined'), + 'reason is required when declining a request' + ), }) .strict(), ]) diff --git a/apps/sim/lib/api/contracts/byok-keys.ts b/apps/sim/lib/api/contracts/byok-keys.ts index d6ee1be20d7..e0333635231 100644 --- a/apps/sim/lib/api/contracts/byok-keys.ts +++ b/apps/sim/lib/api/contracts/byok-keys.ts @@ -9,6 +9,7 @@ export const byokProviderIdSchema = z.enum([ 'mistral', 'zai', 'kimi', + 'typesafe', 'xai', 'fireworks', 'together', diff --git a/apps/sim/lib/api/contracts/invitations.ts b/apps/sim/lib/api/contracts/invitations.ts index ea21b56c7a5..5605918bc12 100644 --- a/apps/sim/lib/api/contracts/invitations.ts +++ b/apps/sim/lib/api/contracts/invitations.ts @@ -1,7 +1,7 @@ import { z } from 'zod' import { organizationIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' -import { workspacePermissionSchema } from '@/lib/api/contracts/workspaces' +import { workspacePermissionSchema, workspaceSchema } from '@/lib/api/contracts/workspaces' import { MAX_INVITE_EMAILS, MAX_INVITE_WORKSPACES } from '@/lib/invitations/limits' export { MAX_INVITE_EMAILS, MAX_INVITE_WORKSPACES } from '@/lib/invitations/limits' @@ -194,6 +194,7 @@ export const invitationDetailsSchema = z.object({ z.object({ workspaceId: z.string(), workspaceName: z.string().nullable(), + workspaceLogoUrl: workspaceSchema.shape.logoUrl, permission: workspacePermissionSchema, }) ), diff --git a/apps/sim/lib/api/contracts/v2/required-field-messages.test.ts b/apps/sim/lib/api/contracts/v2/required-field-messages.test.ts index e72f4e2a57d..6611281a779 100644 --- a/apps/sim/lib/api/contracts/v2/required-field-messages.test.ts +++ b/apps/sim/lib/api/contracts/v2/required-field-messages.test.ts @@ -2,6 +2,8 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' +import { resolveAccessRequestBodySchema } from '@/lib/api/contracts/access-requests' +import { v2ResolveAccessRequestBodySchema } from '@/lib/api/contracts/v2/access-requests' import { v2KnowledgeSearchBodySchema } from '@/lib/api/contracts/v2/knowledge' import { v2CreateSkillBodySchema } from '@/lib/api/contracts/v2/skills' import { v2CreateWorkflowBodySchema } from '@/lib/api/contracts/v2/workflows' @@ -16,6 +18,42 @@ function messageAt( return result.error?.issues.find((issue) => issue.path[0] === field)?.message } +describe.each([ + ['internal', resolveAccessRequestBodySchema], + ['v2', v2ResolveAccessRequestBodySchema], +] as const)('%s access request decisions name missing required fields', (_surface, schema) => { + it.each([ + [ + 'apply', + 'expectedFingerprint', + 'expectedFingerprint is required; preview the request before applying it', + ], + ['decline', 'reason', 'reason is required when declining a request'], + ] as const)('names the missing field for %s', (action, field, message) => { + expect(messageAt(schema.safeParse({ action }), field)).toBe(message) + expect(messageAt(schema.safeParse({ action, [field]: 123 }), field)).toBe( + 'Invalid input: expected string, received number' + ) + }) + + it('preserves decision validation and trimming', () => { + expect(schema.safeParse({ action: 'apply', expectedFingerprint: '' }).success).toBe(false) + expect( + schema.safeParse({ action: 'apply', expectedFingerprint: 'x'.repeat(129) }).success + ).toBe(false) + expect(schema.safeParse({ action: 'decline', reason: ' ' }).success).toBe(false) + expect(schema.safeParse({ action: 'decline', reason: 'x'.repeat(1001) }).success).toBe(false) + expect(schema.parse({ action: 'apply', expectedFingerprint: 'reviewed' })).toEqual({ + action: 'apply', + expectedFingerprint: 'reviewed', + }) + expect(schema.parse({ action: 'decline', reason: ' Not needed ' })).toEqual({ + action: 'decline', + reason: 'Not needed', + }) + }) +}) + /** * A required field that is *omitted* and one that is *wrong-typed* are different * mistakes. Both used to answer with wording that pointed at the other: the diff --git a/apps/sim/lib/billing/constants.ts b/apps/sim/lib/billing/constants.ts index ebe5e7c17b5..c1fd884a293 100644 --- a/apps/sim/lib/billing/constants.ts +++ b/apps/sim/lib/billing/constants.ts @@ -39,6 +39,15 @@ export const DEFAULT_OVERAGE_THRESHOLD = 100 */ export const BILLING_LOCK_TIMEOUT_MS = 5_000 +/** + * Bound on one ledger sum. A large payer's period covers millions of rows, and from a cold + * cache or under heavy I/O the sum can run for tens of seconds; past this the database ends it + * and the read fails, so a caller that admits on the sum fails closed rather than waiting + * without limit. The usage gate derives its coalescing deadline from this bound, so the sum + * always ends at the database before the gate gives up on it. + */ +export const USAGE_LEDGER_STATEMENT_TIMEOUT_MS = 60_000 + /** * Available credit tiers. Each tier maps a credit amount to the underlying dollar * cost and carries that tier's fixed weekly refresh allowance. diff --git a/apps/sim/lib/billing/core/ledger-read.test.ts b/apps/sim/lib/billing/core/ledger-read.test.ts new file mode 100644 index 00000000000..0f7e77f5323 --- /dev/null +++ b/apps/sim/lib/billing/core/ledger-read.test.ts @@ -0,0 +1,35 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { USAGE_LEDGER_STATEMENT_TIMEOUT_MS } from '@/lib/billing/constants' +import { readLedgerBounded } from '@/lib/billing/core/ledger-read' +import type { DbClient } from '@/lib/db/types' + +const renderedSql = (statement: unknown) => + (statement as { toSQL: () => { sql: string } }).toSQL().sql + +describe('readLedgerBounded', () => { + const execute = vi.fn().mockResolvedValue([]) + const tx = { execute } + const transaction = vi.fn((callback: (client: typeof tx) => Promise) => callback(tx)) + const executor = { transaction } as unknown as DbClient + + beforeEach(() => vi.clearAllMocks()) + + it('bounds the statement inside one transaction on the given client, before the read', async () => { + const read = vi.fn().mockResolvedValue([{ cost: '12.5' }]) + await expect(readLedgerBounded(executor, read)).resolves.toEqual([{ cost: '12.5' }]) + expect(transaction).toHaveBeenCalledTimes(1) + expect(execute.mock.calls.map(([statement]) => renderedSql(statement))).toEqual([ + `SET LOCAL statement_timeout = '${USAGE_LEDGER_STATEMENT_TIMEOUT_MS}ms'`, + ]) + expect(read).toHaveBeenCalledWith(tx) + expect(execute.mock.invocationCallOrder[0]).toBeLessThan(read.mock.invocationCallOrder[0]) + }) + + it('surfaces the read failure to the caller', async () => { + const failure = new Error('canceling statement due to statement timeout') + await expect(readLedgerBounded(executor, () => Promise.reject(failure))).rejects.toBe(failure) + }) +}) diff --git a/apps/sim/lib/billing/core/ledger-read.ts b/apps/sim/lib/billing/core/ledger-read.ts new file mode 100644 index 00000000000..577d1b7b5d0 --- /dev/null +++ b/apps/sim/lib/billing/core/ledger-read.ts @@ -0,0 +1,25 @@ +import { sql } from 'drizzle-orm' +import { USAGE_LEDGER_STATEMENT_TIMEOUT_MS } from '@/lib/billing/constants' +import type { DbClient, DbTransaction } from '@/lib/db/types' + +/** + * Runs one aggregate over a payer's usage ledger in a transaction of its own, bounded by + * {@link USAGE_LEDGER_STATEMENT_TIMEOUT_MS}. `SET LOCAL` scopes the bound to that transaction, + * so it ends with the read and never reaches the pool. Every sum over a payer's billing period + * reads through here, whether it admits a run, closes a cycle or previews a bill: a payer whose + * period has grown past what one statement can sum within the bound fails at the database + * instead of holding a connection without limit, and a caller that admits on the answer can + * size its own deadline from the bound. Reads keyed to one execution or one stamped period + * boundary, and the platform-wide admin analytics, are not period sums and read directly. + */ +export function readLedgerBounded( + executor: DbClient, + read: (tx: DbTransaction) => Promise +): Promise { + return executor.transaction(async (tx) => { + await tx.execute( + sql.raw(`SET LOCAL statement_timeout = '${USAGE_LEDGER_STATEMENT_TIMEOUT_MS}ms'`) + ) + return read(tx) + }) +} diff --git a/apps/sim/lib/billing/core/usage-gate-cache.test.ts b/apps/sim/lib/billing/core/usage-gate-cache.test.ts index 752ae97e762..3a073fc0640 100644 --- a/apps/sim/lib/billing/core/usage-gate-cache.test.ts +++ b/apps/sim/lib/billing/core/usage-gate-cache.test.ts @@ -16,6 +16,7 @@ import { checkIngestionUsageLimits, checkSearchUsageLimits, resetUsageGateCache, + USAGE_GATE_SETTLE_TIMEOUT_MS, USAGE_GATE_TTL_MS, } from '@/lib/billing/core/usage-gate-cache' @@ -178,4 +179,36 @@ describe('checkExecutionUsageLimits', () => { await checkExecutionUsageLimits(ATTRIBUTION) expect(mockCheck).toHaveBeenCalledTimes(2) }) + + it('waits for a slow ledger read past the singleflight default instead of blocking', async () => { + vi.useFakeTimers() + try { + mockCheck.mockImplementationOnce(() => sleep(45_000).then(() => ({ isExceeded: false }))) + const pending = checkExecutionUsageLimits(ATTRIBUTION) + await vi.advanceTimersByTimeAsync(45_000) + await expect(pending).resolves.toEqual({ isExceeded: false }) + expect(mockCheck).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) + + it('gives up on a read that never answers at the gate deadline, then reads fresh', async () => { + vi.useFakeTimers() + try { + mockCheck.mockReturnValueOnce(new Promise(() => {})) + const hung = checkExecutionUsageLimits(ATTRIBUTION) + const rejection = expect(hung).rejects.toThrow( + `did not settle within ${USAGE_GATE_SETTLE_TIMEOUT_MS}ms` + ) + await vi.advanceTimersByTimeAsync(USAGE_GATE_SETTLE_TIMEOUT_MS) + await rejection + await expect(checkExecutionUsageLimits(ATTRIBUTION)).resolves.toEqual({ + isExceeded: false, + }) + expect(mockCheck).toHaveBeenCalledTimes(2) + } finally { + vi.useRealTimers() + } + }) }) diff --git a/apps/sim/lib/billing/core/usage-gate-cache.ts b/apps/sim/lib/billing/core/usage-gate-cache.ts index 4e3a1284226..a520220b3bf 100644 --- a/apps/sim/lib/billing/core/usage-gate-cache.ts +++ b/apps/sim/lib/billing/core/usage-gate-cache.ts @@ -1,4 +1,5 @@ import { LRUCache } from 'lru-cache' +import { USAGE_LEDGER_STATEMENT_TIMEOUT_MS } from '@/lib/billing/constants' import { type AttributedUsageLimitsResult, type BillingAttributionSnapshot, @@ -20,6 +21,19 @@ import { coalesceLocally } from '@/lib/concurrency/singleflight' */ export const USAGE_GATE_TTL_MS = 5 * 60 * 1000 +/** + * How long a coalesced usage read may take before its callers give up on it. The read's cost is + * its ledger aggregates, each of which the database ends at + * {@link USAGE_LEDGER_STATEMENT_TIMEOUT_MS}; at most two run in sequence (the payer's usage, + * then a member's cap), and the remainder is a few indexed lookups and the connection waits + * around them. The singleflight default of 30 s exists to bound a hung producer, and a slow + * aggregate is not a hung one: given up on early, it keeps running detached while every joined + * caller fails and the next caller starts a second one alongside it. Sized from the statement + * bound so the database always ends the aggregates first, and the gate only gives up on a + * connection that never answers. + */ +export const USAGE_GATE_SETTLE_TIMEOUT_MS = 2 * USAGE_LEDGER_STATEMENT_TIMEOUT_MS + 15_000 + /** * Recent gate answers, admitted and refused, with `LRUCache` supplying the TTL * and the size bound. Each entry point decides which of them it may serve. @@ -62,9 +76,9 @@ function gateKey(attribution: BillingAttributionSnapshot): string { * the cache. A read that throws writes nothing. * * `coalesceLocally` collapses concurrent misses onto one ledger read and bounds - * a hung read at its settle deadline. The write stays on the value this caller - * received, so a producer that timed out and later resolved cannot overwrite a - * fresher answer. + * a hung read at {@link USAGE_GATE_SETTLE_TIMEOUT_MS}. The write stays on the + * value this caller received, so a producer that timed out and later resolved + * cannot overwrite a fresher answer. * * There is deliberately no invalidator: usage and limit changes land in other * processes (execution workers, Stripe webhooks), so the TTL is the real bound. @@ -77,8 +91,10 @@ async function checkUsageLimitsThroughCache( const cached = gateCache.get(key) if (cached !== undefined && (cacheRefusals || !cached.isExceeded)) return cached - const result = await coalesceLocally(`usage-gate:${key}`, () => - checkAttributedUsageLimits(attribution) + const result = await coalesceLocally( + `usage-gate:${key}`, + () => checkAttributedUsageLimits(attribution), + USAGE_GATE_SETTLE_TIMEOUT_MS ) if (cacheRefusals || !result.isExceeded) gateCache.set(key, result) return result diff --git a/apps/sim/lib/billing/core/usage-log.test.ts b/apps/sim/lib/billing/core/usage-log.test.ts index cd612889af2..f595e67cf2e 100644 --- a/apps/sim/lib/billing/core/usage-log.test.ts +++ b/apps/sim/lib/billing/core/usage-log.test.ts @@ -33,9 +33,15 @@ vi.mock('@/lib/billing/subscriptions/utils', () => ({ isOrgScopedSubscription: mockIsOrgScopedSubscription, })) +import { USAGE_LEDGER_STATEMENT_TIMEOUT_MS } from '@/lib/billing/constants' import { CUMULATIVE_COST_EPSILON, CumulativeUsageContextMismatchError, + getBillingPeriodUsageCost, + getBillingPeriodUsageCostByUser, + getBillingPeriodUsageCostWithSourceSubset, + getBillingPeriodWorkflowRunCount, + getStampedPeriodRangeUsageCostByUser, getUserUsageLogs, getWorkspaceUsageLogs, recordCumulativeUsage, @@ -554,3 +560,84 @@ describe('usage-log query scopes', () => { }) }) }) + +describe('ledger aggregates', () => { + const billingEntity = { type: 'organization' as const, id: 'org-1' } + const billingPeriod = { + start: new Date('2026-05-01T00:00:00Z'), + end: new Date('2027-05-01T00:00:00Z'), + } + /** Every aggregate over the ledger, with the row the mocked read hands back and the value it yields. */ + const aggregates: Array<{ + name: string + read: () => Promise + rows: unknown[] + expected: unknown + }> = [ + { + name: 'getBillingPeriodUsageCost', + read: () => getBillingPeriodUsageCost(billingEntity, billingPeriod), + rows: [{ cost: '12.5' }], + expected: 12.5, + }, + { + name: 'getBillingPeriodWorkflowRunCount', + read: () => getBillingPeriodWorkflowRunCount(billingEntity, billingPeriod), + rows: [{ workflowRuns: 7 }], + expected: 7, + }, + { + name: 'getBillingPeriodUsageCostWithSourceSubset', + read: () => + getBillingPeriodUsageCostWithSourceSubset(billingEntity, billingPeriod, ['workflow']), + rows: [{ total: '20', subset: '5' }], + expected: { total: 20, subset: 5 }, + }, + { + name: 'getBillingPeriodUsageCostByUser', + read: () => getBillingPeriodUsageCostByUser(billingEntity, billingPeriod), + rows: [{ userId: 'user-1', cost: '3' }], + expected: new Map([['user-1', 3]]), + }, + { + name: 'getStampedPeriodRangeUsageCostByUser', + read: () => + getStampedPeriodRangeUsageCostByUser(billingEntity, { + from: billingPeriod.start, + to: billingPeriod.end, + }), + rows: [{ userId: 'user-2', cost: '4' }], + expected: new Map([['user-2', 4]]), + }, + ] + + beforeEach(() => { + vi.clearAllMocks() + installSharedDbMocks() + }) + + for (const aggregate of aggregates) { + it(`${aggregate.name} reads through the bounded ledger transaction`, async () => { + const execute = vi.fn().mockResolvedValue([]) + const terminal = vi.fn().mockResolvedValue(aggregate.rows) + const chain: Record = {} + for (const step of ['select', 'from', 'where', 'leftJoin']) chain[step] = vi.fn(() => chain) + chain.groupBy = terminal + chain.then = (resolve: (rows: unknown[]) => unknown) => terminal().then(resolve) + const tx = { execute, select: chain.select } + mockTransaction.mockImplementation((callback: (client: typeof tx) => Promise) => + callback(tx) + ) + + await expect(aggregate.read()).resolves.toEqual(aggregate.expected) + expect(mockTransaction).toHaveBeenCalledTimes(1) + expect( + execute.mock.calls.map( + ([statement]) => (statement as { toSQL: () => { sql: string } }).toSQL().sql + ) + ).toEqual([`SET LOCAL statement_timeout = '${USAGE_LEDGER_STATEMENT_TIMEOUT_MS}ms'`]) + /** The bound is set before the aggregate runs, not after. */ + expect(execute.mock.invocationCallOrder[0]).toBeLessThan(terminal.mock.invocationCallOrder[0]) + }) + } +}) diff --git a/apps/sim/lib/billing/core/usage-log.ts b/apps/sim/lib/billing/core/usage-log.ts index b79653f0e49..e44e1ba314f 100644 --- a/apps/sim/lib/billing/core/usage-log.ts +++ b/apps/sim/lib/billing/core/usage-log.ts @@ -16,6 +16,7 @@ import { timestampKey, } from '@/lib/api/list-query' import { defaultBillingPeriod } from '@/lib/billing/core/billing-period' +import { readLedgerBounded } from '@/lib/billing/core/ledger-read' import { getHighestPrioritySubscription } from '@/lib/billing/core/plan' import { resolveSubscriptionUsagePeriod, @@ -238,12 +239,14 @@ export async function getBillingPeriodUsageCost( ) } - const [row] = await executor - .select({ - cost: sql`COALESCE(SUM(${usageLog.cost}), 0)`, - }) - .from(usageLog) - .where(and(...conditions)) + const [row] = await readLedgerBounded(executor, (tx) => + tx + .select({ + cost: sql`COALESCE(SUM(${usageLog.cost}), 0)`, + }) + .from(usageLog) + .where(and(...conditions)) + ) return Number.parseFloat(row?.cost ?? '0') } @@ -264,36 +267,38 @@ export async function getBillingPeriodWorkflowRunCount( billingPeriod: UsageQueryPeriod, executor: DbClient = db ): Promise { - const [row] = await executor - .select({ - /** - * The exclusion goes through `notInArray`, not `<> ALL(${array})`. Interpolating - * a JavaScript array into a `sql` template emits parenthesized scalar binds — - * `ALL(($1))` — which Postgres rejects outright with "op ANY/ALL (array) - * requires array on right side". Unit tests cannot catch it, because `@sim/db` - * is mocked and no statement is ever rendered. - */ - workflowRuns: - sql`COUNT(DISTINCT ${usageLog.executionId}) FILTER (WHERE ${usageLog.source} = 'workflow' AND ${notInArray(usageLog.category, [...UNBILLED_USAGE_CATEGORIES])})`.mapWith( - Number - ), - }) - .from(usageLog) - .where( - and( - eq(usageLog.billingEntityType, billingEntity.type), - eq(usageLog.billingEntityId, billingEntity.id), - ...(billingPeriod.source === 'reporting' - ? [ - gte(usageLog.createdAt, billingPeriod.start), - lt(usageLog.createdAt, billingPeriod.end), - ] - : [ - eq(usageLog.billingPeriodStart, billingPeriod.start), - eq(usageLog.billingPeriodEnd, billingPeriod.end), - ]) + const [row] = await readLedgerBounded(executor, (tx) => + tx + .select({ + /** + * The exclusion goes through `notInArray`, not `<> ALL(${array})`. Interpolating + * a JavaScript array into a `sql` template emits parenthesized scalar binds — + * `ALL(($1))` — which Postgres rejects outright with "op ANY/ALL (array) + * requires array on right side". Unit tests cannot catch it, because `@sim/db` + * is mocked and no statement is ever rendered. + */ + workflowRuns: + sql`COUNT(DISTINCT ${usageLog.executionId}) FILTER (WHERE ${usageLog.source} = 'workflow' AND ${notInArray(usageLog.category, [...UNBILLED_USAGE_CATEGORIES])})`.mapWith( + Number + ), + }) + .from(usageLog) + .where( + and( + eq(usageLog.billingEntityType, billingEntity.type), + eq(usageLog.billingEntityId, billingEntity.id), + ...(billingPeriod.source === 'reporting' + ? [ + gte(usageLog.createdAt, billingPeriod.start), + lt(usageLog.createdAt, billingPeriod.end), + ] + : [ + eq(usageLog.billingPeriodStart, billingPeriod.start), + eq(usageLog.billingPeriodEnd, billingPeriod.end), + ]) + ) ) - ) + ) return row?.workflowRuns ?? 0 } @@ -311,27 +316,29 @@ export async function getBillingPeriodUsageCostWithSourceSubset( source: UsageLogSource[], executor: DbClient = db ): Promise<{ total: number; subset: number }> { - const [row] = await executor - .select({ - total: sql`COALESCE(SUM(${usageLog.cost}), 0)`, - subset: sql`COALESCE(SUM(${usageLog.cost}) FILTER (WHERE ${inArray(usageLog.source, source)}), 0)`, - }) - .from(usageLog) - .where( - and( - eq(usageLog.billingEntityType, billingEntity.type), - eq(usageLog.billingEntityId, billingEntity.id), - ...(billingPeriod.source === 'reporting' - ? [ - gte(usageLog.createdAt, billingPeriod.start), - lt(usageLog.createdAt, billingPeriod.end), - ] - : [ - eq(usageLog.billingPeriodStart, billingPeriod.start), - eq(usageLog.billingPeriodEnd, billingPeriod.end), - ]) + const [row] = await readLedgerBounded(executor, (tx) => + tx + .select({ + total: sql`COALESCE(SUM(${usageLog.cost}), 0)`, + subset: sql`COALESCE(SUM(${usageLog.cost}) FILTER (WHERE ${inArray(usageLog.source, source)}), 0)`, + }) + .from(usageLog) + .where( + and( + eq(usageLog.billingEntityType, billingEntity.type), + eq(usageLog.billingEntityId, billingEntity.id), + ...(billingPeriod.source === 'reporting' + ? [ + gte(usageLog.createdAt, billingPeriod.start), + lt(usageLog.createdAt, billingPeriod.end), + ] + : [ + eq(usageLog.billingPeriodStart, billingPeriod.start), + eq(usageLog.billingPeriodEnd, billingPeriod.end), + ]) + ) ) - ) + ) return { total: Number.parseFloat(row?.total ?? '0'), @@ -367,14 +374,16 @@ export async function getBillingPeriodUsageCostByUser( } if (userIds) conditions.push(inArray(usageLog.userId, [...userIds])) - const rows = await executor - .select({ - userId: usageLog.userId, - cost: sql`COALESCE(SUM(${usageLog.cost}), 0)`, - }) - .from(usageLog) - .where(and(...conditions)) - .groupBy(usageLog.userId) + const rows = await readLedgerBounded(executor, (tx) => + tx + .select({ + userId: usageLog.userId, + cost: sql`COALESCE(SUM(${usageLog.cost}), 0)`, + }) + .from(usageLog) + .where(and(...conditions)) + .groupBy(usageLog.userId) + ) return new Map(rows.map((row) => [row.userId, Number.parseFloat(row.cost ?? '0')])) } @@ -408,14 +417,16 @@ export async function getStampedPeriodRangeUsageCostByUser( ) } - const rows = await executor - .select({ - userId: usageLog.userId, - cost: sql`COALESCE(SUM(${usageLog.cost}), 0)`, - }) - .from(usageLog) - .where(and(...conditions)) - .groupBy(usageLog.userId) + const rows = await readLedgerBounded(executor, (tx) => + tx + .select({ + userId: usageLog.userId, + cost: sql`COALESCE(SUM(${usageLog.cost}), 0)`, + }) + .from(usageLog) + .where(and(...conditions)) + .groupBy(usageLog.userId) + ) return new Map(rows.map((row) => [row.userId, Number.parseFloat(row.cost ?? '0')])) } diff --git a/apps/sim/lib/billing/credits/weekly-refresh.test.ts b/apps/sim/lib/billing/credits/weekly-refresh.test.ts index 95344da6943..d1a796e73ab 100644 --- a/apps/sim/lib/billing/credits/weekly-refresh.test.ts +++ b/apps/sim/lib/billing/credits/weekly-refresh.test.ts @@ -3,6 +3,7 @@ */ import { dbChainMockFns, drizzleOrmMock, schemaMock } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { USAGE_LEDGER_STATEMENT_TIMEOUT_MS } from '@/lib/billing/constants' vi.mock('drizzle-orm', () => { const sqlTag = () => { @@ -11,7 +12,9 @@ vi.mock('drizzle-orm', () => { } return { ...drizzleOrmMock, - sql: Object.assign(sqlTag, { raw: sqlTag }), + sql: Object.assign(sqlTag, { + raw: (rawSql: string) => ({ rawSql, toSQL: () => ({ sql: rawSql, params: [] }) }), + }), sum: () => ({ as: () => 'sum' }), } }) @@ -49,6 +52,14 @@ describe('computeBillingPeriodUsageWithWeeklyRefresh', () => { { ledgerTotal: '25.00', refreshWeekTotal: '12.00' }, { ledgerTotal: '25.00', refreshWeekTotal: '4.00' }, ]) + /** The scan is a ledger aggregate: it runs inside the bounded ledger transaction. */ + const boundBeforeScan = () => + dbChainMockFns.transaction.mock.calls.length === 1 && + dbChainMockFns.execute.mock.calls.some(([statement]) => + String((statement as { toSQL?: () => { sql: string } }).toSQL?.().sql).includes( + `SET LOCAL statement_timeout = '${USAGE_LEDGER_STATEMENT_TIMEOUT_MS}ms'` + ) + ) await expect( computeBillingPeriodUsageWithWeeklyRefresh({ @@ -65,6 +76,7 @@ describe('computeBillingPeriodUsageWithWeeklyRefresh', () => { periodStart ) expect(drizzleOrmMock.eq).toHaveBeenCalledWith(schemaMock.usageLog.billingPeriodEnd, periodEnd) + expect(boundBeforeScan()).toBe(true) }) it('uses the reporting time range for the ledger while retaining captured-period refresh', async () => { diff --git a/apps/sim/lib/billing/credits/weekly-refresh.ts b/apps/sim/lib/billing/credits/weekly-refresh.ts index 8779fc7389f..a12de029d63 100644 --- a/apps/sim/lib/billing/credits/weekly-refresh.ts +++ b/apps/sim/lib/billing/credits/weekly-refresh.ts @@ -24,6 +24,7 @@ import { db } from '@sim/db' import { usageLog } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, gte, lt, or, sql, sum } from 'drizzle-orm' +import { readLedgerBounded } from '@/lib/billing/core/ledger-read' import type { BillingEntity, UsageQueryPeriod } from '@/lib/billing/core/usage-log' import type { DbClient } from '@/lib/db/types' @@ -88,29 +89,31 @@ export async function computeBillingPeriodUsageWithWeeklyRefresh( const startEpoch = Math.floor(refreshPeriodStart.getTime() / 1000) const capEpoch = Math.floor(cap.getTime() / 1000) - const rows = await executor - .select({ - weekIndex: - sql`FLOOR((LEAST(GREATEST(EXTRACT(EPOCH FROM ${usageLog.createdAt}), ${startEpoch}), ${capEpoch - 1}) - ${startEpoch}) / 604800)`.as( - 'week_index' + const rows = await readLedgerBounded(executor, (tx) => + tx + .select({ + weekIndex: + sql`FLOOR((LEAST(GREATEST(EXTRACT(EPOCH FROM ${usageLog.createdAt}), ${startEpoch}), ${capEpoch - 1}) - ${startEpoch}) / 604800)`.as( + 'week_index' + ), + ledgerTotal: + sql`SUM(SUM(${usageLog.cost}) FILTER (WHERE ${ledgerPeriodFilter})) OVER ()`.as( + 'ledger_total' + ), + refreshWeekTotal: sql`SUM(${usageLog.cost}) FILTER (WHERE ${refreshFilter})`.as( + 'refresh_week_total' ), - ledgerTotal: - sql`SUM(SUM(${usageLog.cost}) FILTER (WHERE ${ledgerPeriodFilter})) OVER ()`.as( - 'ledger_total' - ), - refreshWeekTotal: sql`SUM(${usageLog.cost}) FILTER (WHERE ${refreshFilter})`.as( - 'refresh_week_total' - ), - }) - .from(usageLog) - .where( - and( - eq(usageLog.billingEntityType, billingEntity.type), - eq(usageLog.billingEntityId, billingEntity.id), - scanFilter + }) + .from(usageLog) + .where( + and( + eq(usageLog.billingEntityType, billingEntity.type), + eq(usageLog.billingEntityId, billingEntity.id), + scanFilter + ) ) - ) - .groupBy(sql`week_index`) + .groupBy(sql`week_index`) + ) let refreshConsumed = 0 for (const row of rows) { @@ -169,23 +172,25 @@ export async function computeWeeklyRefreshConsumed( // refresh in the period's final week rather than fall out of the deduction. const startEpoch = Math.floor(periodStart.getTime() / 1000) const capEpoch = Math.floor(cap.getTime() / 1000) - const rows = await executor - .select({ - weekIndex: - sql`FLOOR((LEAST(GREATEST(EXTRACT(EPOCH FROM ${usageLog.createdAt}), ${startEpoch}), ${capEpoch - 1}) - ${startEpoch}) / 604800)`.as( - 'week_index' - ), - weekTotal: sum(usageLog.cost).as('week_total'), - }) - .from(usageLog) - .where( - and( - eq(usageLog.billingEntityType, billingEntity.type), - eq(usageLog.billingEntityId, billingEntity.id), - eq(usageLog.billingPeriodStart, periodStart) + const rows = await readLedgerBounded(executor, (tx) => + tx + .select({ + weekIndex: + sql`FLOOR((LEAST(GREATEST(EXTRACT(EPOCH FROM ${usageLog.createdAt}), ${startEpoch}), ${capEpoch - 1}) - ${startEpoch}) / 604800)`.as( + 'week_index' + ), + weekTotal: sum(usageLog.cost).as('week_total'), + }) + .from(usageLog) + .where( + and( + eq(usageLog.billingEntityType, billingEntity.type), + eq(usageLog.billingEntityId, billingEntity.id), + eq(usageLog.billingPeriodStart, periodStart) + ) ) - ) - .groupBy(sql`week_index`) + .groupBy(sql`week_index`) + ) let totalConsumed = 0 for (const row of rows) { diff --git a/apps/sim/lib/billing/enterprise-provisioning.test.ts b/apps/sim/lib/billing/enterprise-provisioning.test.ts index 58afc58ab15..e235536af8b 100644 --- a/apps/sim/lib/billing/enterprise-provisioning.test.ts +++ b/apps/sim/lib/billing/enterprise-provisioning.test.ts @@ -740,6 +740,245 @@ describe('Enterprise creation invitations', () => { }) }) + it.each(['admin', 'owner'] as const)( + 'recognizes inherited organization %s access without explicit workspace grants', + async (role) => { + const payload = operationPayload({ + request: { + ...operationPayload().request, + workspaceIds: ['workspace-1', 'workspace-2'], + }, + applicationResult: { + appliedAt: '2026-08-13T00:00:00.000Z', + subscriptionId: 'sub-1', + }, + }) + queueTableRows(schemaMock.outboxEvent, [ + { eventType: 'stripe.provision-enterprise', payload }, + ]) + queueTableRows(schemaMock.outboxEvent, []) + queueTableRows(schemaMock.outboxEvent, [{ status: 'completed' }, { status: 'completed' }]) + queueTableRows( + schemaMock.user, + ['workspace-1', 'workspace-2'].map((workspaceId) => ({ + userId: 'invitee-1', + workspaceId, + role, + permission: null, + })) + ) + const checkpointPayload = vi.fn() + + await inviteEnterprisePeople( + { + provisioningOperationId: 'operation-1', + organizationId: 'org-1', + ownerUserId: 'owner-1', + email: 'new@example.com', + role: 'admin', + permission: 'admin', + sequence: 0, + }, + { + eventId: 'invite-1', + eventType: 'enterprise.invite-people', + attempts: 0, + checkpointPayload, + } + ) + + expect(checkpointPayload).toHaveBeenCalledExactlyOnceWith({ + delivery: { + completedAt: expect.any(String), + resultId: 'invitee-1', + outcome: 'unchanged', + }, + }) + expect(mocks.createWorkspaceInvitation).not.toHaveBeenCalled() + expect(mocks.prepareWorkspaceInvitationContext).not.toHaveBeenCalled() + expect(mocks.sendInvitationEmail).not.toHaveBeenCalled() + } + ) + + it.each([ + { + name: 'a concurrent promotion', + role: 'admin', + permission: null, + requestedRole: 'member', + workspaceIds: ['workspace-1'], + applied: true, + }, + { + name: 'a sufficient explicit grant', + role: 'member', + permission: 'write', + requestedRole: 'member', + workspaceIds: ['workspace-1'], + applied: true, + }, + { + name: 'an insufficient explicit grant', + role: 'member', + permission: 'read', + requestedRole: 'member', + workspaceIds: ['workspace-1'], + applied: false, + }, + { + name: 'a workspace leaving the organization scope', + role: null, + permission: null, + requestedRole: 'member', + workspaceIds: ['workspace-1'], + applied: false, + }, + { + name: 'a workspace admin grant without the requested organization admin role', + role: 'member', + permission: 'admin', + requestedRole: 'admin', + workspaceIds: ['workspace-1'], + applied: false, + }, + { + name: 'inherited access to only one of two requested workspaces', + role: 'admin', + permission: null, + requestedRole: 'member', + workspaceIds: ['workspace-1', 'workspace-2'], + applied: false, + }, + ] as const)( + 'checks the final effective access after $name', + async ({ role, permission, requestedRole, workspaceIds, applied }) => { + const payload = operationPayload({ + request: { ...operationPayload().request, workspaceIds: [...workspaceIds] }, + applicationResult: { + appliedAt: '2026-08-13T00:00:00.000Z', + subscriptionId: 'sub-1', + }, + }) + queueTableRows(schemaMock.outboxEvent, [ + { eventType: 'stripe.provision-enterprise', payload }, + ]) + queueTableRows(schemaMock.outboxEvent, []) + queueTableRows( + schemaMock.outboxEvent, + workspaceIds.map(() => ({ status: 'completed' })) + ) + queueTableRows(schemaMock.user, [ + { userId: 'invitee-1', workspaceId: 'workspace-1', role: 'member', permission: null }, + ]) + queueTableRows(schemaMock.invitation, []) + queueTableRows(schemaMock.user, [{ organizationId: 'org-1' }]) + queueTableRows(schemaMock.user, [ + { id: 'owner-1', name: 'Owner', email: 'owner@example.com' }, + ]) + queueTableRows( + schemaMock.user, + role ? [{ userId: 'invitee-1', workspaceId: 'workspace-1', role, permission }] : [] + ) + queueTableRows(schemaMock.invitation, []) + mocks.createWorkspaceInvitation.mockResolvedValueOnce({ + id: 'invitee-1', + instantAdd: true, + outcome: 'unchanged', + workspaceIds: [], + }) + const checkpointPayload = vi.fn() + const result = inviteEnterprisePeople( + { + provisioningOperationId: 'operation-1', + organizationId: 'org-1', + ownerUserId: 'owner-1', + email: 'new@example.com', + role: requestedRole, + permission: 'write', + sequence: 0, + }, + { + eventId: 'invite-1', + eventType: 'enterprise.invite-people', + attempts: 0, + checkpointPayload, + } + ) + + if (applied) { + await expect(result).resolves.toBeUndefined() + expect(checkpointPayload).toHaveBeenLastCalledWith({ + delivery: { + completedAt: expect.any(String), + resultId: 'invitee-1', + outcome: 'unchanged', + }, + }) + } else { + await expect(result).rejects.toThrow( + 'did not apply the requested organization role and workspace permissions' + ) + expect(checkpointPayload).toHaveBeenCalledExactlyOnceWith({ + attemptedAt: expect.any(String), + }) + } + expect(mocks.createWorkspaceInvitation).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ existingAccessPolicy: 'ensure-at-least' }) + ) + expect(mocks.sendInvitationEmail).not.toHaveBeenCalled() + } + ) + + it('reads applied access on an archived selected workspace, which the selection keeps', async () => { + const payload = operationPayload({ + request: { ...operationPayload().request, workspaceIds: ['workspace-1'] }, + applicationResult: { appliedAt: '2026-08-13T00:00:00.000Z', subscriptionId: 'sub-1' }, + }) + queueTableRows(schemaMock.outboxEvent, [{ eventType: 'stripe.provision-enterprise', payload }]) + queueTableRows(schemaMock.outboxEvent, []) + queueTableRows(schemaMock.outboxEvent, [{ status: 'completed' }]) + queueTableRows(schemaMock.user, [ + { userId: 'invitee-1', workspaceId: 'workspace-1', role: 'admin', permission: 'admin' }, + ]) + queueTableRows(schemaMock.invitation, []) + + await expect( + inviteEnterprisePeople( + { + provisioningOperationId: 'operation-1', + organizationId: 'org-1', + ownerUserId: 'owner-1', + email: 'new@example.com', + role: 'member', + permission: 'write', + sequence: 0, + }, + { + eventId: 'invite-1', + eventType: 'enterprise.invite-people', + attempts: 0, + checkpointPayload: vi.fn(), + } + ) + ).resolves.toBeUndefined() + + /** + * The applied-state join scopes workspaces to the organization and the selection only. + * An archived filter here would turn already-applied access on an archived selected + * workspace into a missing one, scheduling an invitation the archived workspace refuses. + */ + const joinConditions = dbChainMockFns.innerJoin.mock.calls.map(([, condition]) => + JSON.stringify(condition) + ) + const workspaceJoin = joinConditions.find((condition) => + condition.includes('workspace.organizationId') + ) + expect(workspaceJoin).toBeDefined() + expect(workspaceJoin).toContain('"workspace-1"') + expect(workspaceJoin).not.toContain('workspace.archivedAt') + expect(mocks.createWorkspaceInvitation).not.toHaveBeenCalled() + }) + it('waits without consuming attempts until every selected workspace move completes', async () => { const payload = operationPayload({ request: { diff --git a/apps/sim/lib/billing/enterprise-provisioning.ts b/apps/sim/lib/billing/enterprise-provisioning.ts index 6a5f1152037..bbdd9479cb1 100644 --- a/apps/sim/lib/billing/enterprise-provisioning.ts +++ b/apps/sim/lib/billing/enterprise-provisioning.ts @@ -2746,36 +2746,52 @@ async function resolveEnterpriseInvitationApplicationState( workspaceIds: string[] ): Promise { const normalizedEmail = normalizeEmail(payload.email) - const [existingUser] = await db - .select({ id: user.id, organizationId: member.organizationId, role: member.role }) + const accessRows = await db + .select({ + userId: user.id, + workspaceId: workspace.id, + role: member.role, + permission: permissions.permissionType, + }) .from(user) - .leftJoin(member, eq(member.userId, user.id)) - .where(eq(user.normalizedEmail, normalizedEmail)) - .limit(1) - const roleSatisfied = - existingUser?.organizationId === payload.organizationId && - (payload.role === 'member' || isOrgAdminRole(existingUser.role)) - if (existingUser && roleSatisfied) { - const accessRows = await db - .select({ workspaceId: permissions.entityId, permission: permissions.permissionType }) - .from(permissions) - .where( - and( - eq(permissions.entityType, 'workspace'), - eq(permissions.userId, existingUser.id), - inArray(permissions.entityId, workspaceIds) - ) + .innerJoin( + member, + and(eq(member.userId, user.id), eq(member.organizationId, payload.organizationId)) + ) + /** + * Archived workspaces stay in the join: the selection keeps them on purpose (the move + * carries them into the organization so they can be unarchived later), so a recipient + * whose access on one is already in place must read as applied, or the sweep would + * schedule an invitation the archived workspace cannot accept and never converge. + */ + .innerJoin( + workspace, + and(eq(workspace.organizationId, member.organizationId), inArray(workspace.id, workspaceIds)) + ) + .leftJoin( + permissions, + and( + eq(permissions.entityType, 'workspace'), + eq(permissions.userId, user.id), + eq(permissions.entityId, workspace.id) ) - const accessByWorkspace = new Map( - accessRows.map((row) => [row.workspaceId, row.permission] as const) ) - if ( - workspaceIds.every((workspaceId) => - permissionSatisfies(accessByWorkspace.get(workspaceId), payload.permission) + .where(eq(user.normalizedEmail, normalizedEmail)) + const accessByWorkspace = new Map(accessRows.map((row) => [row.workspaceId, row] as const)) + const existingUserId = accessRows[0]?.userId + if ( + existingUserId && + workspaceIds.every((workspaceId) => { + const access = accessByWorkspace.get(workspaceId) + if (!access) return false + const inheritsAdmin = isOrgAdminRole(access.role) + return ( + (payload.role === 'member' || inheritsAdmin) && + (inheritsAdmin || permissionSatisfies(access.permission, payload.permission)) ) - ) { - return { kind: 'applied', resultId: existingUser.id } - } + }) + ) { + return { kind: 'applied', resultId: existingUserId } } const pendingRows = await db diff --git a/apps/sim/lib/billing/organizations/member-limits.test.ts b/apps/sim/lib/billing/organizations/member-limits.test.ts index 8c1c9e60782..aed15bf0b53 100644 --- a/apps/sim/lib/billing/organizations/member-limits.test.ts +++ b/apps/sim/lib/billing/organizations/member-limits.test.ts @@ -3,6 +3,7 @@ */ import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { USAGE_LEDGER_STATEMENT_TIMEOUT_MS } from '@/lib/billing/constants' const { schemaTables, @@ -57,7 +58,10 @@ vi.mock('drizzle-orm', () => ({ isNull: mockIsNull, lt: mockLt, or: mockOr, - sql: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ strings, values })), + sql: Object.assign( + vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ strings, values })), + { raw: (rawSql: string) => ({ rawSql, toSQL: () => ({ sql: rawSql, params: [] }) }) } + ), })) vi.mock('@/lib/billing/core/billing', () => ({ @@ -109,6 +113,16 @@ describe('getOrgMemberUsageForBillingPeriod', () => { getOrgMemberUsageForBillingPeriod('snapshot-org', 'actor-2', billingPeriod) ).resolves.toBe(4.5) + /** The member sum is a ledger aggregate: it runs inside the bounded ledger transaction. */ + expect(dbChainMockFns.transaction).toHaveBeenCalledTimes(1) + expect( + dbChainMockFns.execute.mock.calls.some(([statement]) => + String((statement as { toSQL?: () => { sql: string } }).toSQL?.().sql).includes( + `SET LOCAL statement_timeout = '${USAGE_LEDGER_STATEMENT_TIMEOUT_MS}ms'` + ) + ) + ).toBe(true) + expect(mockEq).toHaveBeenCalledWith('usageLog.billingEntityType', 'organization') expect(mockEq).toHaveBeenCalledWith('usageLog.billingEntityId', 'snapshot-org') expect(mockEq).toHaveBeenCalledWith('usageLog.userId', 'actor-2') diff --git a/apps/sim/lib/billing/organizations/member-limits.ts b/apps/sim/lib/billing/organizations/member-limits.ts index 94b6c294ece..a78f8e98c93 100644 --- a/apps/sim/lib/billing/organizations/member-limits.ts +++ b/apps/sim/lib/billing/organizations/member-limits.ts @@ -11,6 +11,7 @@ import { generateId } from '@sim/utils/id' import { and, eq, gte, isNull, lt, or, sql } from 'drizzle-orm' import { getOrganizationSubscription } from '@/lib/billing/core/billing' import { defaultBillingPeriod } from '@/lib/billing/core/billing-period' +import { readLedgerBounded } from '@/lib/billing/core/ledger-read' import { resolveSubscriptionUsagePeriod } from '@/lib/billing/core/reporting-period' import type { UsageQueryPeriod } from '@/lib/billing/core/usage-log' import { toDecimal, toNumber } from '@/lib/billing/utils/decimal' @@ -143,43 +144,45 @@ export async function getOrgMemberUsageForBillingPeriod( userId: string, billingPeriod: UsageQueryPeriod ): Promise { - const [row] = await db - .select({ cost: sql`COALESCE(SUM(${usageLog.cost}), 0)` }) - .from(usageLog) - .leftJoin(workspace, eq(workspace.id, usageLog.workspaceId)) - .where( - and( - eq(usageLog.userId, userId), - ...(billingPeriod.source === 'reporting' - ? [ - eq(usageLog.billingEntityType, 'organization'), - eq(usageLog.billingEntityId, organizationId), - gte(usageLog.createdAt, billingPeriod.start), - lt(usageLog.createdAt, billingPeriod.end), - ] - : [ - or( - and( - eq(usageLog.billingEntityType, 'organization'), - eq(usageLog.billingEntityId, organizationId), - eq(usageLog.billingPeriodStart, billingPeriod.start), - eq(usageLog.billingPeriodEnd, billingPeriod.end) - ), - and( - isNull(usageLog.billingEntityType), - isNull(usageLog.billingEntityId), - eq(workspace.organizationId, organizationId), - or( - isNull(workspace.organizationAssignedAt), - gte(usageLog.createdAt, workspace.organizationAssignedAt) + const [row] = await readLedgerBounded(db, (tx) => + tx + .select({ cost: sql`COALESCE(SUM(${usageLog.cost}), 0)` }) + .from(usageLog) + .leftJoin(workspace, eq(workspace.id, usageLog.workspaceId)) + .where( + and( + eq(usageLog.userId, userId), + ...(billingPeriod.source === 'reporting' + ? [ + eq(usageLog.billingEntityType, 'organization'), + eq(usageLog.billingEntityId, organizationId), + gte(usageLog.createdAt, billingPeriod.start), + lt(usageLog.createdAt, billingPeriod.end), + ] + : [ + or( + and( + eq(usageLog.billingEntityType, 'organization'), + eq(usageLog.billingEntityId, organizationId), + eq(usageLog.billingPeriodStart, billingPeriod.start), + eq(usageLog.billingPeriodEnd, billingPeriod.end) ), - gte(usageLog.createdAt, billingPeriod.start), - lt(usageLog.createdAt, billingPeriod.end) - ) - ), - ]) + and( + isNull(usageLog.billingEntityType), + isNull(usageLog.billingEntityId), + eq(workspace.organizationId, organizationId), + or( + isNull(workspace.organizationAssignedAt), + gte(usageLog.createdAt, workspace.organizationAssignedAt) + ), + gte(usageLog.createdAt, billingPeriod.start), + lt(usageLog.createdAt, billingPeriod.end) + ) + ), + ]) + ) ) - ) + ) return Number.parseFloat(row?.cost ?? '0') } diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 7163ec25cea..9a21aba0699 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -261,6 +261,9 @@ export const env = createEnv({ KIMI_API_KEY_1: z.string().min(1).optional(), // Primary Kimi (Moonshot AI) API key for load balancing KIMI_API_KEY_2: z.string().min(1).optional(), // Additional Kimi API key for load balancing KIMI_API_KEY_3: z.string().min(1).optional(), // Additional Kimi API key for load balancing + TYPESAFE_API_KEY_1: z.string().min(1).optional(), + TYPESAFE_API_KEY_2: z.string().min(1).optional(), + TYPESAFE_API_KEY_3: z.string().min(1).optional(), XAI_API_KEY_1: z.string().min(1).optional(), // Primary xAI API key for load balancing XAI_API_KEY_2: z.string().min(1).optional(), // Additional xAI API key for load balancing XAI_API_KEY_3: z.string().min(1).optional(), // Additional xAI API key for load balancing diff --git a/apps/sim/lib/core/utils.test.ts b/apps/sim/lib/core/utils.test.ts index 1c882094c52..2919ac7dd13 100644 --- a/apps/sim/lib/core/utils.test.ts +++ b/apps/sim/lib/core/utils.test.ts @@ -24,6 +24,9 @@ beforeAll(() => { XAI_API_KEY_1: 'test-xai-key-1', XAI_API_KEY_2: 'test-xai-key-2', XAI_API_KEY_3: 'test-xai-key-3', + TYPESAFE_API_KEY_1: 'test-typesafe-key-1', + TYPESAFE_API_KEY_2: 'test-typesafe-key-2', + TYPESAFE_API_KEY_3: 'test-typesafe-key-3', FIREWORKS_API_KEY_1: 'test-fireworks-key-1', FIREWORKS_API_KEY_2: 'test-fireworks-key-2', FIREWORKS_API_KEY_3: 'test-fireworks-key-3', @@ -321,6 +324,9 @@ describe('getInvalidCharacters', () => { }) describe('getRotatingApiKey', () => { + it.concurrent('rotates the TypeSafe key pool through the shared selector', () => { + expect(getRotatingApiKey('typesafe')).toMatch(/^test-typesafe-key-[1-3]$/) + }) it.concurrent('should return OpenAI API key based on current minute', () => { const result = getRotatingApiKey('openai') expect(result).toMatch(/^test-openai-key-[1-3]$/) diff --git a/apps/sim/lib/memory/bounded-json.test.ts b/apps/sim/lib/core/utils/bounded-json.test.ts similarity index 79% rename from apps/sim/lib/memory/bounded-json.test.ts rename to apps/sim/lib/core/utils/bounded-json.test.ts index 52a5c584a10..3f5e503b57f 100644 --- a/apps/sim/lib/memory/bounded-json.test.ts +++ b/apps/sim/lib/core/utils/bounded-json.test.ts @@ -1,8 +1,8 @@ /** @vitest-environment node */ import { describe, expect, it, vi } from 'vitest' -import { stringifyBoundedMemoryJson } from '@/lib/memory/bounded-json' +import { stringifyBoundedJson } from '@/lib/core/utils/bounded-json' -describe('bounded memory JSON', () => { +describe('bounded JSON', () => { it.each([ { value: { text: 'hello', values: [1, false, null] } }, { value: { text: 'é😀\ud800\udc00\ud800' } }, @@ -12,8 +12,8 @@ describe('bounded memory JSON', () => { ])('uses the caller byte limit including UTF-8 and escaped JSON bytes', ({ value }) => { const json = JSON.stringify(value) const bytes = Buffer.byteLength(json, 'utf8') - expect(stringifyBoundedMemoryJson(value, bytes)).toBe(json) - expect(stringifyBoundedMemoryJson(value, bytes - 1)).toBeUndefined() + expect(stringifyBoundedJson(value, bytes)).toBe(json) + expect(stringifyBoundedJson(value, bytes - 1)).toBeUndefined() }) it('rejects cycles, excessive depth, and excessive nodes', () => { @@ -27,7 +27,7 @@ describe('bounded memory JSON', () => { Array(100_001), Object.fromEntries(Array.from({ length: 100_001 }, (_, index) => [index, undefined])), ]) { - expect(stringifyBoundedMemoryJson(value, 8 * 1024 * 1024)).toBeUndefined() + expect(stringifyBoundedJson(value, 8 * 1024 * 1024)).toBeUndefined() } }) @@ -37,7 +37,7 @@ describe('bounded memory JSON', () => { const accessor = Object.defineProperty({}, 'secret', { enumerable: true, get: getter }) const custom = Object.defineProperty({}, 'toJSON', { value: toJSON }) for (const value of [accessor, custom, { output: new Uint8Array([1, 2, 3]) }]) { - expect(stringifyBoundedMemoryJson(value, 1024)).toBeUndefined() + expect(stringifyBoundedJson(value, 1024)).toBeUndefined() } expect(getter).not.toHaveBeenCalled() expect(toJSON).not.toHaveBeenCalled() @@ -47,7 +47,7 @@ describe('bounded memory JSON', () => { const value = { output: 'x'.repeat(1025) } const serialize = vi.spyOn(JSON, 'stringify') try { - expect(stringifyBoundedMemoryJson(value, 1024)).toBeUndefined() + expect(stringifyBoundedJson(value, 1024)).toBeUndefined() expect(serialize).not.toHaveBeenCalled() } finally { serialize.mockRestore() @@ -61,7 +61,7 @@ describe('bounded memory JSON', () => { ])('rejects escaped bytes before serializing the captured graph', ({ value }) => { const serialize = vi.spyOn(JSON, 'stringify') try { - expect(stringifyBoundedMemoryJson(value, 1024)).toBeUndefined() + expect(stringifyBoundedJson(value, 1024)).toBeUndefined() expect(serialize).not.toHaveBeenCalled() } finally { serialize.mockRestore() @@ -71,7 +71,7 @@ describe('bounded memory JSON', () => { it('serializes the admitted descriptors without reading proxy values or toJSON', () => { const get = vi.fn(() => 'UNADMITTED') const value = new Proxy({ text: 'admitted' }, { get }) - expect(stringifyBoundedMemoryJson(value, 1024)).toBe('{"text":"admitted"}') + expect(stringifyBoundedJson(value, 1024)).toBe('{"text":"admitted"}') expect(get).not.toHaveBeenCalled() }) @@ -79,13 +79,13 @@ describe('bounded memory JSON', () => { const get = vi.fn(() => 'UNADMITTED') const prototype = Object.create(Array.prototype, { 0: { get } }) const value = Object.setPrototypeOf(Array(1), prototype) - expect(stringifyBoundedMemoryJson(value, 1024)).toBe('[null]') + expect(stringifyBoundedJson(value, 1024)).toBe('[null]') expect(get).not.toHaveBeenCalled() }) it('allows repeated references without treating them as a cycle', () => { const result = { answer: 42 } const value = { rawResponse: result, modelResponse: result } - expect(stringifyBoundedMemoryJson(value, 1024)).toBe(JSON.stringify(value)) + expect(stringifyBoundedJson(value, 1024)).toBe(JSON.stringify(value)) }) }) diff --git a/apps/sim/lib/memory/bounded-json.ts b/apps/sim/lib/core/utils/bounded-json.ts similarity index 89% rename from apps/sim/lib/memory/bounded-json.ts rename to apps/sim/lib/core/utils/bounded-json.ts index 698d1afbbe5..55678f5cb3a 100644 --- a/apps/sim/lib/memory/bounded-json.ts +++ b/apps/sim/lib/core/utils/bounded-json.ts @@ -1,5 +1,5 @@ -const MAX_MEMORY_JSON_NODES = 100_000 -const MAX_MEMORY_JSON_DEPTH = 64 +const MAX_JSON_NODES = 100_000 +const MAX_JSON_DEPTH = 64 /** Counts JSON escapes without allocating the escaped string. */ function quotedStringBytes(value: string, remaining: number): number | undefined { @@ -22,7 +22,7 @@ function quotedStringBytes(value: string, remaining: number): number | undefined } /** Captures bounded plain JSON once, without executing accessors or serializing the source graph. */ -export function stringifyBoundedMemoryJson(value: unknown, maxBytes: number): string | undefined { +export function stringifyBoundedJson(value: unknown, maxBytes: number): string | undefined { let nodes = 0 let bytes = 0 const invalid = Symbol('invalid JSON') @@ -32,7 +32,7 @@ export function stringifyBoundedMemoryJson(value: unknown, maxBytes: number): st return bytes <= maxBytes } const capture = (item: unknown, depth: number): unknown => { - if (++nodes > MAX_MEMORY_JSON_NODES || depth > MAX_MEMORY_JSON_DEPTH) return invalid + if (++nodes > MAX_JSON_NODES || depth > MAX_JSON_DEPTH) return invalid if (typeof item === 'string') { const count = quotedStringBytes(item, maxBytes - bytes) if (count === undefined || !addBytes(count)) return invalid @@ -53,7 +53,7 @@ export function stringifyBoundedMemoryJson(value: unknown, maxBytes: number): st : Object.create(null) if (isArray) { const length = Object.getOwnPropertyDescriptor(item, 'length')?.value - if (typeof length !== 'number' || length > MAX_MEMORY_JSON_NODES - nodes) return invalid + if (typeof length !== 'number' || length > MAX_JSON_NODES - nodes) return invalid for (let index = 0; index < length; index++) { const field = Object.getOwnPropertyDescriptor(item, index) if (field && !('value' in field)) return invalid @@ -69,7 +69,7 @@ export function stringifyBoundedMemoryJson(value: unknown, maxBytes: number): st if (!field || !field.enumerable) continue if (!('value' in field)) return invalid if (field.value === undefined) { - if (++nodes > MAX_MEMORY_JSON_NODES) return invalid + if (++nodes > MAX_JSON_NODES) return invalid continue } const keyBytes = quotedStringBytes(key, maxBytes - bytes) diff --git a/apps/sim/lib/credential-groups/application/authorization.test.ts b/apps/sim/lib/credential-groups/application/authorization.test.ts index 8638bed8885..081e9a7dbe0 100644 --- a/apps/sim/lib/credential-groups/application/authorization.test.ts +++ b/apps/sim/lib/credential-groups/application/authorization.test.ts @@ -276,6 +276,37 @@ describe('requireCredentialGroupCredentialAccess', () => { await expect(requireAccess(executorPrincipal())).rejects.toMatchObject({ code: 'not_found' }) }) + it('requires a live connector grant to execute a managed MCP credential', async () => { + mocks.loadBinding.mockResolvedValue(null) + const managedContext = { ...context, credentialType: 'mcp:fireflies' as const } + const principal = executorPrincipal() + const requireManagedAccess = () => + requireCredentialGroupCredentialAccess( + principal, + managedContext, + credentialOperations.useManagedMcp.resourcePolicy + ) + + await expect(requireManagedAccess()).resolves.toBeUndefined() + + mocks.requirePolicy.mockResolvedValue(storedPolicy([])) + await expect(requireManagedAccess()).rejects.toMatchObject({ code: 'forbidden' }) + + mocks.requirePolicy.mockResolvedValue({ + document: buildOrganizationAccountAccessPolicy('group-1', [ + { + workspaceId: context.workspaceId, + access: { mode: 'selected', credentialTypes: ['oauth:gmail'] }, + }, + ]), + }) + await expect(requireManagedAccess()).rejects.toMatchObject({ code: 'forbidden' }) + + mocks.requirePolicy.mockResolvedValue(storedPolicy()) + mocks.isAvailable.mockResolvedValue(false) + await expect(requireManagedAccess()).rejects.toMatchObject({ code: 'not_found' }) + }) + it('rejects inconsistent Sim and external subject assertions before loading policy', async () => { const simPrincipal = executorPrincipal() simPrincipal.subjectUserId = 'user-2' diff --git a/apps/sim/lib/credentials/draft-hooks.test.ts b/apps/sim/lib/credentials/draft-hooks.test.ts index dbbd887f51f..57114ca55e9 100644 --- a/apps/sim/lib/credentials/draft-hooks.test.ts +++ b/apps/sim/lib/credentials/draft-hooks.test.ts @@ -103,6 +103,7 @@ describe('handleReconnectCredential', () => { { id: 'credential-1', accountId: null, displayName: 'Renamed Gmail' }, ]) queueTableRows(schemaMock.credential, []) + queueTableRows(schemaMock.account, [{ providerId: 'gmail', accountId: 'subject-new' }]) await handleReconnectCredential({ draft: { credentialId: 'credential-1' }, @@ -115,6 +116,15 @@ describe('handleReconnectCredential', () => { expect(mocks.clearDeadFlag).toHaveBeenCalledWith( getOAuthRefreshCoordinationIdentity('account-new') ) + /** Connectors the rejected credential had unscheduled are due again. */ + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'active', + lastSyncError: null, + consecutiveFailures: 0, + nextSyncAt: new Date('2026-08-14T18:00:00.000Z'), + }) + ) expect(auditMockFns.mockRecordAudit).toHaveBeenCalledWith( expect.objectContaining({ resourceId: 'credential-1', diff --git a/apps/sim/lib/credentials/draft-hooks.ts b/apps/sim/lib/credentials/draft-hooks.ts index 6cf0b4c9f1f..ed5bff2dc36 100644 --- a/apps/sim/lib/credentials/draft-hooks.ts +++ b/apps/sim/lib/credentials/draft-hooks.ts @@ -6,6 +6,7 @@ import { getPostgresConstraintName, getPostgresErrorCode } from '@sim/utils/erro import { generateId } from '@sim/utils/id' import { and, eq, sql } from 'drizzle-orm' import { deleteOrphanedOAuthAccount } from '@/lib/credentials/deletion' +import { resumeConnectorsAfterCredentialReconnect } from '@/lib/knowledge/connectors/credential-recovery' import { clearOAuthRefreshDeadFlag } from '@/lib/oauth/refresh-coordination' import { captureServerEvent } from '@/lib/posthog/server' @@ -75,6 +76,7 @@ export async function handleCreateCredentialFromDraft(params: { .where(eq(schema.credential.id, existingCredential.id)) await clearOAuthRefreshDeadFlag(accountId) + await resumeConnectorsAfterCredentialReconnect(accountId, now) recordAudit({ workspaceId: draft.workspaceId, @@ -209,6 +211,7 @@ export async function handleReconnectCredential(params: { ) await clearOAuthRefreshDeadFlag(newAccountId) + await resumeConnectorsAfterCredentialReconnect(newAccountId, now) recordAudit({ workspaceId, diff --git a/apps/sim/lib/credentials/organization-draft.ts b/apps/sim/lib/credentials/organization-draft.ts index 7bc0c03fd69..0bf7f948de9 100644 --- a/apps/sim/lib/credentials/organization-draft.ts +++ b/apps/sim/lib/credentials/organization-draft.ts @@ -8,6 +8,7 @@ import { OrchestrationError } from '@/lib/core/orchestration/types' import { resourceScopeCondition } from '@/lib/core/resource-scope.server' import { deleteOrphanedOAuthAccount } from '@/lib/credentials/deletion' import { getCredentialCreationOrganizationContext } from '@/lib/credentials/organization' +import { resumeConnectorsAfterCredentialReconnect } from '@/lib/knowledge/connectors/credential-recovery' import { clearOAuthRefreshDeadFlag } from '@/lib/oauth/refresh-coordination' /** Completes the exact draft bound to the authenticated provider callback, rechecking current ownership under membership locks. */ @@ -124,6 +125,7 @@ export async function completeOrganizationCredentialDraft(input: { } }) await clearOAuthRefreshDeadFlag(input.accountId) + if (result.reconnected) await resumeConnectorsAfterCredentialReconnect(input.accountId, now) recordAudit({ actorId: input.userId, action: result.reconnected diff --git a/apps/sim/lib/invitations/core.test.ts b/apps/sim/lib/invitations/core.test.ts index d9d796e5e34..8eebbd61907 100644 --- a/apps/sim/lib/invitations/core.test.ts +++ b/apps/sim/lib/invitations/core.test.ts @@ -90,6 +90,7 @@ vi.mock('@sim/audit', () => auditMock) import { acceptInvitation, + getInvitationById, rejectInvitation, resolveInvitationAdmissionOrganizationId, revokeInvitationAsAdmin, @@ -128,6 +129,39 @@ function executedSqlContaining(substring: string): boolean { afterAll(resetEnvFlagsMock) +describe('invitation workspace identity', () => { + it('hydrates uploaded logos and workspaces without a logo in the grant query', async () => { + resetDbChainMock() + queueWhereResponses([ + [{ id: 'invitation', organizationId: null, inviterId: 'inviter' }], + [ + { + id: 'grant-one', + workspaceId: 'one', + workspaceName: 'Design', + workspaceLogoUrl: 'https://example.com/design.png', + permission: 'read', + }, + { + id: 'grant-two', + workspaceId: 'two', + workspaceName: 'Engineering', + workspaceLogoUrl: null, + permission: 'write', + }, + ], + [{ name: 'Inviter', email: 'inviter@example.com' }], + ]) + + const result = await getInvitationById('invitation') + + expect(result?.grants.map((grant) => grant.workspaceLogoUrl)).toEqual([ + 'https://example.com/design.png', + null, + ]) + }) +}) + describe('acceptInvitation', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/lib/invitations/core.ts b/apps/sim/lib/invitations/core.ts index 2bd155871f3..4d7583295a6 100644 --- a/apps/sim/lib/invitations/core.ts +++ b/apps/sim/lib/invitations/core.ts @@ -71,6 +71,7 @@ export interface InvitationWithGrants { workspaceId: string permission: 'admin' | 'write' | 'read' workspaceName: string | null + workspaceLogoUrl: string | null }> organizationName: string | null inviterName: string | null @@ -221,6 +222,7 @@ async function hydrateInvitation( workspaceId: invitationWorkspaceGrant.workspaceId, permission: invitationWorkspaceGrant.permission, workspaceName: workspace.name, + workspaceLogoUrl: workspace.logoUrl, }) .from(invitationWorkspaceGrant) .leftJoin(workspace, eq(workspace.id, invitationWorkspaceGrant.workspaceId)) @@ -266,6 +268,7 @@ async function hydrateInvitation( workspaceId: grant.workspaceId, permission: grant.permission, workspaceName: grant.workspaceName, + workspaceLogoUrl: grant.workspaceLogoUrl, })), organizationName, inviterName: inviterRow?.name ?? null, diff --git a/apps/sim/lib/invitations/direct-grant.test.ts b/apps/sim/lib/invitations/direct-grant.test.ts index cf225e72b66..cdf19ff5d8b 100644 --- a/apps/sim/lib/invitations/direct-grant.test.ts +++ b/apps/sim/lib/invitations/direct-grant.test.ts @@ -145,13 +145,40 @@ describe('grantWorkspaceAccessDirectly', () => { expect(dbChainMockFns.for.mock.invocationCallOrder[0]).toBeLessThan( mockGetEffectiveWorkspacePermission.mock.invocationCallOrder[0] ) - expect(dbChainMockFns.for).toHaveBeenCalledTimes(3) + expect(dbChainMockFns.for).toHaveBeenCalledTimes(4) expect(dbChainMockFns.for.mock.invocationCallOrder[1]).toBeLessThan( mockGetEffectiveWorkspacePermission.mock.invocationCallOrder[0] ) expect(dbChainMockFns.from).toHaveBeenCalledWith(member) }) + it.each(['admin', 'owner'] as const)( + 'preserves an invitee who became organization %s before the transaction without redundant effects', + async (role) => { + mockGetUserOrganization.mockResolvedValueOnce({ organizationId: 'org-1', role }) + + const result = await grantWorkspaceAccessDirectly({ + ...baseInput, + existingPermissionPolicy: 'ensure-at-least', + }) + + expect(result).toEqual({ outcome: 'unchanged', permission: 'admin' }) + expect(mockAcquireOrganizationUserMutationLocks.mock.invocationCallOrder[0]).toBeLessThan( + dbChainMockFns.for.mock.invocationCallOrder[1] + ) + expect(dbChainMockFns.for.mock.invocationCallOrder[1]).toBeLessThan( + mockGetUserOrganization.mock.invocationCallOrder[0] + ) + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + expect(dbChainMockFns.update).not.toHaveBeenCalled() + expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled() + expect(mockSyncWorkspaceEnvCredentials).not.toHaveBeenCalled() + expect(auditMockFns.mockRecordAudit).not.toHaveBeenCalled() + expect(mockWorkspaceMemberAdded).not.toHaveBeenCalled() + expect(mockCaptureServerEvent).not.toHaveBeenCalled() + } + ) + it('delivers the transactionally enqueued notification through the outbox', async () => { await directGrantOutboxHandlers[DIRECT_GRANT_EMAIL_EVENT_TYPE]( { diff --git a/apps/sim/lib/invitations/direct-grant.ts b/apps/sim/lib/invitations/direct-grant.ts index e23f27da23d..56877067ad1 100644 --- a/apps/sim/lib/invitations/direct-grant.ts +++ b/apps/sim/lib/invitations/direct-grant.ts @@ -10,7 +10,7 @@ import { workspaceEnvironment, } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { permissionSatisfies } from '@sim/platform-authz/workspace' +import { isOrgAdminRole, permissionSatisfies } from '@sim/platform-authz/workspace' import { generateId } from '@sim/utils/id' import { isRecordLike } from '@sim/utils/object' import { normalizeEmail } from '@sim/utils/string' @@ -179,6 +179,13 @@ export async function grantWorkspaceAccessDirectly( and(eq(member.userId, input.actorId), eq(member.organizationId, input.organizationId)) ) .for('update') + await tx + .select({ id: member.id }) + .from(member) + .where( + and(eq(member.userId, input.userId), eq(member.organizationId, input.organizationId)) + ) + .for('update') const workspaceRow = await getWorkspaceWithOwner(input.workspaceId, { executor: tx, @@ -228,7 +235,9 @@ export async function grantWorkspaceAccessDirectly( .limit(1) let outcome: DirectGrantOutcome - if (existing) { + if (isOrgAdminRole(inviteeMembership.role)) { + outcome = { outcome: 'unchanged', permission: 'admin' } + } else if (existing) { const existingPermission = existing.permissionType as PermissionType if ( input.existingPermissionPolicy === 'ensure-at-least' && diff --git a/apps/sim/lib/invitations/workspace-invitations.test.ts b/apps/sim/lib/invitations/workspace-invitations.test.ts index d3eaa085368..541b27057ee 100644 --- a/apps/sim/lib/invitations/workspace-invitations.test.ts +++ b/apps/sim/lib/invitations/workspace-invitations.test.ts @@ -264,6 +264,336 @@ describe('createWorkspaceInvitation', () => { expect(mockCreatePendingInvitation).not.toHaveBeenCalled() }) + it.each(['admin', 'owner'] as const)( + 'rejects inviting an organization %s who already inherits workspace access', + async (role) => { + queueTableRows(userTable, [{ id: 'user-2', email: 'member@example.com' }]) + queueTableRows(member, [{ role: 'owner' }]) + queueTableRows(member, [{ role }]) + mockGetUserOrganization.mockResolvedValueOnce({ + organizationId: 'org-1', + memberId: 'member-2', + role, + }) + + await expect( + createWorkspaceInvitation({ + context: makeContext(['ws-1', 'ws-2']), + email: 'member@example.com', + permission: 'write', + request, + }) + ).rejects.toThrow('already has access to every selected workspace') + + expect(mockGrantWorkspaceAccessDirectly).not.toHaveBeenCalled() + expect(mockCreatePendingInvitation).not.toHaveBeenCalled() + expect(mockSendInvitationEmail).not.toHaveBeenCalled() + expect(auditMockFns.mockRecordAudit).not.toHaveBeenCalled() + expect(mockAcquireOrganizationUserMutationLocks).toHaveBeenCalledOnce() + expect(dbChainMockFns.update).not.toHaveBeenCalled() + } + ) + + it.each(['member', 'admin'] as const)( + 'preserves current membership after a concurrent demotion in ordinary %s invitations', + async (membership) => { + queueTableRows(userTable, [{ id: 'user-2' }]) + queueTableRows(member, [{ role: 'member' }]) + queueTableRows(member, [{ role: 'member' }]) + mockGetUserOrganization.mockResolvedValueOnce({ + organizationId: 'org-1', + memberId: 'member-2', + role: 'admin', + }) + + const result = await createWorkspaceInvitation({ + context: makeContext(), + email: 'member@example.com', + permission: 'write', + membership, + request, + }) + + expect(result).toMatchObject({ outcome: 'added', workspaceIds: ['ws-1'] }) + expect(mockAcquireOrganizationUserMutationLocks.mock.invocationCallOrder[0]).toBeLessThan( + mockGrantWorkspaceAccessDirectly.mock.invocationCallOrder[0] + ) + expect(mockGrantWorkspaceAccessDirectly).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ userId: 'user-2', existingPermissionPolicy: 'preserve' }) + ) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + expect(auditMockFns.mockRecordAudit).not.toHaveBeenCalled() + expect(mockCreatePendingInvitation).not.toHaveBeenCalled() + } + ) + + it.each(['admin', 'owner'] as const)( + 'leaves inherited access unchanged when ensuring access for an organization %s', + async (role) => { + queueTableRows(userTable, [{ id: 'user-2', email: 'member@example.com' }]) + queueTableRows(member, [{ role: 'owner' }]) + queueTableRows(member, [{ role }]) + mockGetUserOrganization.mockResolvedValueOnce({ + organizationId: 'org-1', + memberId: 'member-2', + role, + }) + + const result = await createWorkspaceInvitation({ + context: makeContext(['ws-1', 'ws-2']), + email: 'member@example.com', + permission: 'admin', + membership: 'admin', + existingAccessPolicy: 'ensure-at-least', + request, + }) + + expect(result).toMatchObject({ + workspaceIds: [], + instantAdd: true, + outcome: 'unchanged', + membershipIntent: 'internal', + }) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + expect(mockGrantWorkspaceAccessDirectly).not.toHaveBeenCalled() + expect(mockCreatePendingInvitation).not.toHaveBeenCalled() + expect(mockSendInvitationEmail).not.toHaveBeenCalled() + expect(auditMockFns.mockRecordAudit).not.toHaveBeenCalled() + } + ) + + it.each(['member', 'admin'])( + 'reports a current member promotion correctly after initially observing %s', + async (observedRole) => { + queueTableRows(userTable, [{ id: 'user-2' }]) + queueTableRows(member, [{ role: 'owner' }]) + queueTableRows(member, [{ role: 'member' }]) + mockGetUserOrganization.mockResolvedValueOnce({ + organizationId: 'org-1', + memberId: 'member-2', + role: observedRole, + }) + + const result = await createWorkspaceInvitation({ + context: makeContext(['ws-1', 'ws-2']), + email: 'member@example.com', + permission: 'write', + membership: 'admin', + existingAccessPolicy: 'ensure-at-least', + request, + }) + + expect(result).toMatchObject({ + workspaceIds: [], + instantAdd: true, + outcome: 'updated', + membershipIntent: 'internal', + }) + expect(dbChainMockFns.update).toHaveBeenCalledExactlyOnceWith(member) + expect(dbChainMockFns.set).toHaveBeenCalledWith({ role: 'admin' }) + expect(auditMockFns.mockRecordAudit).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + action: 'org_member.role_changed', + metadata: expect.objectContaining({ previousRole: 'member', newRole: 'admin' }), + }) + ) + expect(mockGrantWorkspaceAccessDirectly).not.toHaveBeenCalled() + expect(mockCreatePendingInvitation).not.toHaveBeenCalled() + expect(mockSendInvitationEmail).not.toHaveBeenCalled() + } + ) + + it('reconciles workspace access when an inherited admin was demoted before the locked check', async () => { + queueTableRows(userTable, [{ id: 'user-2' }]) + queueTableRows(member, [{ role: 'member' }]) + queueTableRows(member, [{ role: 'member' }]) + mockGetUserOrganization.mockResolvedValueOnce({ + organizationId: 'org-1', + memberId: 'member-2', + role: 'admin', + }) + + const result = await createWorkspaceInvitation({ + context: makeContext(), + email: 'member@example.com', + membership: 'member', + permission: 'write', + existingAccessPolicy: 'ensure-at-least', + }) + + expect(result).toMatchObject({ + outcome: 'added', + workspaceIds: ['ws-1'], + instantAdd: true, + }) + expect(mockAcquireInvitationMutationLocks).toHaveBeenCalledExactlyOnceWith(expect.anything(), { + invitationIds: [], + workspaceIds: ['ws-1'], + }) + expect(mockAcquireInvitationMutationLocks.mock.invocationCallOrder[0]).toBeLessThan( + mockAcquireOrganizationUserMutationLocks.mock.invocationCallOrder[0] + ) + expect(mockAcquireOrganizationUserMutationLocks.mock.invocationCallOrder[0]).toBeLessThan( + dbChainMockFns.for.mock.invocationCallOrder[0] + ) + expect(mockGetEffectiveWorkspacePermission).toHaveBeenCalledExactlyOnceWith( + 'user-1', + expect.objectContaining({ id: 'ws-1', organizationId: 'org-1' }), + expect.anything() + ) + expect(mockGrantWorkspaceAccessDirectly).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ userId: 'user-2', permission: 'write' }) + ) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + expect(mockCreatePendingInvitation).not.toHaveBeenCalled() + }) + + it('lets workspace admins preserve current inherited access without organization-admin authority', async () => { + queueTableRows(userTable, [{ id: 'user-2' }]) + queueTableRows(member, [{ role: 'member' }]) + queueTableRows(member, [{ role: 'admin' }]) + mockGetUserOrganization.mockResolvedValueOnce({ + organizationId: 'org-1', + memberId: 'member-2', + role: 'admin', + }) + + const result = await createWorkspaceInvitation({ + context: makeContext(), + email: 'member@example.com', + membership: 'member', + existingAccessPolicy: 'ensure-at-least', + }) + + expect(result).toMatchObject({ outcome: 'unchanged', workspaceIds: [] }) + expect(mockGetEffectiveWorkspacePermission).toHaveBeenCalled() + expect(dbChainMockFns.update).not.toHaveBeenCalled() + expect(mockGrantWorkspaceAccessDirectly).not.toHaveBeenCalled() + expect(mockSendInvitationEmail).not.toHaveBeenCalled() + }) + + it('excludes workspaces already covered when the direct grant observes a concurrent promotion', async () => { + queueTableRows(userTable, [{ id: 'user-2' }]) + mockGetUserOrganization.mockResolvedValueOnce({ + organizationId: 'org-1', + memberId: 'member-2', + role: 'member', + }) + mockGrantWorkspaceAccessDirectly.mockResolvedValueOnce({ + outcome: 'unchanged', + permission: 'admin', + }) + + const result = await createWorkspaceInvitation({ + context: makeContext(), + email: 'member@example.com', + existingAccessPolicy: 'ensure-at-least', + }) + + expect(result).toMatchObject({ outcome: 'unchanged', workspaceIds: [], instantAdd: true }) + expect(mockGrantWorkspaceAccessDirectly).toHaveBeenCalledOnce() + expect(mockCreatePendingInvitation).not.toHaveBeenCalled() + expect(mockSendInvitationEmail).not.toHaveBeenCalled() + }) + + it('rejects inherited access reconciliation when the workspace changed organizations', async () => { + queueTableRows(userTable, [{ id: 'user-2' }]) + queueTableRows(member, [{ role: 'owner' }]) + queueTableRows(member, [{ role: 'admin' }]) + mockGetUserOrganization.mockResolvedValueOnce({ + organizationId: 'org-1', + memberId: 'member-2', + role: 'admin', + }) + mockGetWorkspaceWithOwner.mockResolvedValueOnce(makeTarget('ws-1', 'org-2').workspaceDetails) + + await expect( + createWorkspaceInvitation({ + context: makeContext(), + email: 'member@example.com', + existingAccessPolicy: 'ensure-at-least', + }) + ).rejects.toMatchObject({ status: 409 }) + + expect(dbChainMockFns.update).not.toHaveBeenCalled() + expect(mockGrantWorkspaceAccessDirectly).not.toHaveBeenCalled() + expect(mockSendInvitationEmail).not.toHaveBeenCalled() + expect(auditMockFns.mockRecordAudit).not.toHaveBeenCalled() + }) + + it('rejects an inherited-access no-op when the inviter lost workspace admin access', async () => { + queueTableRows(userTable, [{ id: 'user-2' }]) + queueTableRows(member, [{ role: 'member' }]) + queueTableRows(member, [{ role: 'admin' }]) + mockGetUserOrganization.mockResolvedValueOnce({ + organizationId: 'org-1', + memberId: 'member-2', + role: 'admin', + }) + mockGetEffectiveWorkspacePermission.mockResolvedValueOnce('read') + + await expect( + createWorkspaceInvitation({ + context: makeContext(), + email: 'member@example.com', + existingAccessPolicy: 'ensure-at-least', + }) + ).rejects.toMatchObject({ status: 409 }) + + expect(mockGrantWorkspaceAccessDirectly).not.toHaveBeenCalled() + expect(mockSendInvitationEmail).not.toHaveBeenCalled() + expect(auditMockFns.mockRecordAudit).not.toHaveBeenCalled() + }) + + it.each(['admin', 'owner'] as const)( + 'does not inherit workspace access from a different organization %s role', + async (role) => { + queueWhereResponses([[{ id: 'user-3', email: 'ext@example.com' }], []]) + mockGetUserOrganization.mockResolvedValueOnce({ organizationId: 'org-2', role }) + + const result = await createWorkspaceInvitation({ + context: makeContext(), + email: 'ext@example.com', + permission: 'read', + request, + }) + + expect(result.membershipIntent).toBe('external') + expect(result.workspaceIds).toEqual(['ws-1']) + expect(mockGrantWorkspaceAccessDirectly).not.toHaveBeenCalled() + expect(mockCreatePendingInvitation).toHaveBeenCalledWith( + expect.objectContaining({ + membershipIntent: 'external', + grants: [{ workspaceId: 'ws-1', permission: 'read' }], + }) + ) + } + ) + + it('does not treat a foreign organization admin role as a stronger explicit workspace grant', async () => { + queueWhereResponses([ + [{ id: 'user-3', email: 'ext@example.com' }], + [{ workspaceId: 'ws-1', permission: 'read' }], + ]) + mockGetUserOrganization.mockResolvedValueOnce({ organizationId: 'org-2', role: 'admin' }) + + await createWorkspaceInvitation({ + context: makeContext(), + email: 'ext@example.com', + permission: 'write', + existingAccessPolicy: 'ensure-at-least', + request, + }) + + expect(mockCreatePendingInvitation).toHaveBeenCalledWith( + expect.objectContaining({ + membershipIntent: 'external', + grants: [{ workspaceId: 'ws-1', permission: 'write' }], + }) + ) + expect(mockGrantWorkspaceAccessDirectly).not.toHaveBeenCalled() + }) + it('creates an external pending invitation when the user belongs to a different org', async () => { queueWhereResponses([[{ id: 'user-3', email: 'ext@example.com' }], []]) mockGetUserOrganization.mockResolvedValueOnce({ organizationId: 'org-2', role: 'member' }) diff --git a/apps/sim/lib/invitations/workspace-invitations.ts b/apps/sim/lib/invitations/workspace-invitations.ts index 0cf92749162..d29ccaf7771 100644 --- a/apps/sim/lib/invitations/workspace-invitations.ts +++ b/apps/sim/lib/invitations/workspace-invitations.ts @@ -140,17 +140,15 @@ async function ensureExistingMemberOrganizationRole({ request?: OrchestrationRequestContext validateLockedWorkspace?: GrantWorkspaceAccessDirectlyInput['validateLockedWorkspace'] }): Promise<{ role: string; updated: boolean }> { - if (requestedRole !== 'admin' || isOrgAdminRole(currentRole)) { + if (requestedRole !== 'admin' && !isOrgAdminRole(currentRole)) { return { role: currentRole, updated: false } } - const updated = await db.transaction(async (tx) => { - if (validateLockedWorkspace) { - await acquireInvitationMutationLocks(tx, { - invitationIds: [], - workspaceIds: context.targets.map((target) => target.workspaceId), - }) - } + const result = await db.transaction(async (tx) => { + await acquireInvitationMutationLocks(tx, { + invitationIds: [], + workspaceIds: context.targets.map((target) => target.workspaceId), + }) await acquireOrganizationUserMutationLocks(tx, { userId, organizationIds: [organizationId], @@ -173,36 +171,60 @@ async function ensureExistingMemberOrganizationRole({ ) .for('update') .limit(1) - if (!actorMembership || !isOrgAdminRole(actorMembership.role) || !targetMembership) { + const needsPromotion = requestedRole === 'admin' && !isOrgAdminRole(targetMembership?.role) + if (!targetMembership || (needsPromotion && !isOrgAdminRole(actorMembership?.role))) { throw new WorkspaceInvitationError({ message: 'Organization membership changed. Refresh and try again.', status: 409, email, }) } - if (isOrgAdminRole(targetMembership.role)) return false - if (validateLockedWorkspace) { - for (const workspaceId of context.targets.map((target) => target.workspaceId).sort()) { - const workspaceDetails = await getWorkspaceWithOwner(workspaceId, { - executor: tx, - forUpdate: true, + for (const workspaceId of context.targets.map((target) => target.workspaceId).sort()) { + const workspaceDetails = await getWorkspaceWithOwner(workspaceId, { + executor: tx, + forUpdate: true, + }) + if (!workspaceDetails || workspaceDetails.organizationId !== organizationId) { + throw new WorkspaceInvitationError({ + message: + 'A selected workspace changed organizations. Review the selection and try again.', + status: 409, + email, + }) + } + await tx + .select({ id: permissions.id }) + .from(permissions) + .where( + and( + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, workspaceId), + eq(permissions.userId, context.inviterId) + ) + ) + .for('update') + if ( + (await getEffectiveWorkspacePermission(context.inviterId, workspaceDetails, tx)) !== 'admin' + ) { + throw new WorkspaceInvitationError({ + message: 'Your workspace permissions changed. Review the selection and try again.', + status: 409, + email, }) - if (!workspaceDetails || workspaceDetails.organizationId !== organizationId) { - throw new WorkspaceInvitationError({ - message: - 'A selected workspace changed organizations. Review the selection and try again.', - status: 409, - email, - }) - } - await validateLockedWorkspace(tx, workspaceDetails) } + await validateLockedWorkspace?.(tx, workspaceDetails) + } + if (needsPromotion) { + await tx.update(member).set({ role: 'admin' }).where(eq(member.id, memberId)) + } + return { + role: needsPromotion ? 'admin' : targetMembership.role, + updated: needsPromotion, + previousRole: targetMembership.role, } - await tx.update(member).set({ role: 'admin' }).where(eq(member.id, memberId)) - return true }) - if (updated) { + if (result.updated) { recordAudit({ actorId: context.auditActor ? context.auditActor.id : context.inviterId, actorName: context.auditActor ? context.auditActor.name : context.inviterName, @@ -216,13 +238,13 @@ async function ensureExistingMemberOrganizationRole({ ...context.auditActor?.metadata, targetUserId: userId, memberId, - previousRole: currentRole, + previousRole: result.previousRole, newRole: 'admin', }, request, }) } - return { role: 'admin', updated } + return { role: result.role, updated: result.updated } } /** @@ -555,10 +577,10 @@ export async function createWorkspaceInvitation({ let existingOrganizationRole = existingMembership?.role let organizationRoleUpdated = false if ( - existingAccessPolicy === 'ensure-at-least' && existingUser && organizationId && - existingMembership?.organizationId === organizationId + existingMembership?.organizationId === organizationId && + (existingAccessPolicy === 'ensure-at-least' || isOrgAdminRole(existingMembership.role)) ) { const ensuredRole = await ensureExistingMemberOrganizationRole({ context, @@ -566,7 +588,8 @@ export async function createWorkspaceInvitation({ memberId: existingMembership.memberId, userId: existingUser.id, currentRole: existingMembership.role, - requestedRole: membership === 'admin' ? 'admin' : 'member', + requestedRole: + existingAccessPolicy === 'ensure-at-least' && membership === 'admin' ? 'admin' : 'member', email: normalizedEmail, request, validateLockedWorkspace, @@ -577,6 +600,10 @@ export async function createWorkspaceInvitation({ let pendingTargets = context.targets if (existingUser) { + const inheritsWorkspaceAdmin = + organizationId !== null && + existingMembership?.organizationId === organizationId && + isOrgAdminRole(existingOrganizationRole) const accessibleRows = await db .select({ workspaceId: permissions.entityId, permission: permissions.permissionType }) .from(permissions) @@ -588,14 +615,15 @@ export async function createWorkspaceInvitation({ ) ) const accessibleWorkspaceIds = new Set( - accessibleRows - .filter( - (row) => - existingAccessPolicy === 'preserve' || - isOrgAdminRole(existingOrganizationRole) || - permissionSatisfies(row.permission, invitationPermission) - ) - .map((row) => row.workspaceId) + inheritsWorkspaceAdmin + ? allWorkspaceIds + : accessibleRows + .filter( + (row) => + existingAccessPolicy === 'preserve' || + permissionSatisfies(row.permission, invitationPermission) + ) + .map((row) => row.workspaceId) ) /** @@ -637,6 +665,7 @@ export async function createWorkspaceInvitation({ */ if (organizationId && existingMembership?.organizationId === organizationId) { let outcome: DirectGrantOutcome['outcome'] = organizationRoleUpdated ? 'updated' : 'unchanged' + const grantedWorkspaceIds: string[] = [] for (const target of pendingTargets) { let directGrant: DirectGrantOutcome try { @@ -668,6 +697,7 @@ export async function createWorkspaceInvitation({ } throw error } + if (directGrant.outcome !== 'unchanged') grantedWorkspaceIds.push(target.workspaceId) if (directGrant.outcome === 'added') outcome = 'added' else if (directGrant.outcome === 'updated' && outcome === 'unchanged') outcome = 'updated' } @@ -675,7 +705,7 @@ export async function createWorkspaceInvitation({ return { id: existingUser.id, email: normalizedEmail, - workspaceIds: pendingTargets.map((target) => target.workspaceId), + workspaceIds: grantedWorkspaceIds, permission: invitationPermission, membershipIntent: 'internal', instantAdd: true, diff --git a/apps/sim/lib/knowledge/connectors/credential-recovery.test.ts b/apps/sim/lib/knowledge/connectors/credential-recovery.test.ts new file mode 100644 index 00000000000..4b2fe6a2c21 --- /dev/null +++ b/apps/sim/lib/knowledge/connectors/credential-recovery.test.ts @@ -0,0 +1,52 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { resumeConnectorsAfterCredentialReconnect } from '@/lib/knowledge/connectors/credential-recovery' +import { CREDENTIAL_REVOKED_SYNC_ERROR } from '@/lib/knowledge/connectors/sync-limits' + +const RESUMED = { + status: 'active', + lastSyncError: null, + consecutiveFailures: 0, +} + +describe('resumeConnectorsAfterCredentialReconnect', () => { + const now = new Date('2026-09-22T20:00:00.000Z') + + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('resumes the connectors of every credential on the reconnected account', async () => { + queueTableRows(schemaMock.account, [ + { providerId: 'confluence', providerAccountId: 'subject-1' }, + ]) + await resumeConnectorsAfterCredentialReconnect('account-1', now) + expect(dbChainMockFns.set).toHaveBeenCalledWith({ ...RESUMED, nextSyncAt: now, updatedAt: now }) + const guard = JSON.stringify(dbChainMockFns.where.mock.calls.at(-1)) + expect(guard).toContain('knowledgeConnector.credentialId') + expect(guard).toContain(CREDENTIAL_REVOKED_SYNC_ERROR) + const credentials = JSON.stringify(dbChainMockFns.where.mock.calls) + expect(credentials).toContain('"left":"credential.accountId","right":"account-1"') + }) + + it('resumes across the Slack installation, whose sibling accounts share the repaired chain', async () => { + queueTableRows(schemaMock.account, [ + { providerId: 'slack', providerAccountId: 'TEXAMPLE-usr_U1' }, + ]) + await resumeConnectorsAfterCredentialReconnect('account-1', now) + expect(dbChainMockFns.set).toHaveBeenCalledWith({ ...RESUMED, nextSyncAt: now, updatedAt: now }) + const conditions = JSON.stringify(dbChainMockFns.where.mock.calls) + expect(conditions).toContain('"pattern":"TEXAMPLE-%"') + expect(conditions).not.toContain('"left":"credential.accountId","right":"account-1"') + }) + + it('does nothing for an account that no longer exists', async () => { + queueTableRows(schemaMock.account, []) + await resumeConnectorsAfterCredentialReconnect('account-gone', now) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/knowledge/connectors/credential-recovery.ts b/apps/sim/lib/knowledge/connectors/credential-recovery.ts new file mode 100644 index 00000000000..87381dde7bb --- /dev/null +++ b/apps/sim/lib/knowledge/connectors/credential-recovery.ts @@ -0,0 +1,57 @@ +import { db } from '@sim/db' +import { account, credential, knowledgeConnector } from '@sim/db/schema' +import { and, eq, inArray } from 'drizzle-orm' +import { CREDENTIAL_REVOKED_SYNC_ERROR } from '@/lib/knowledge/connectors/sync-limits' +import { extractSlackTeamId, installationFilter, isSlackProvider } from '@/lib/oauth/slack' + +/** + * Puts the connectors a reconnected account had unscheduled back on their schedule. + * + * A sync that finds its credential rejected by the source leaves the connector unscheduled + * with {@link CREDENTIAL_REVOKED_SYNC_ERROR}, since retrying cannot help until someone + * authorizes again. Reauthorizing the account is that moment: every connector on a credential + * of that account still carrying the error is due now, with its failure count cleared. A Slack + * reauthorization repairs the installation's shared token chain, so the connectors on every + * credential of the installation's sibling accounts are due as well. Connectors paused or + * disabled for another reason keep their state, and a connector that already moved on is left + * alone. + */ +export async function resumeConnectorsAfterCredentialReconnect( + accountId: string, + now: Date +): Promise { + const [reconnected] = await db + .select({ providerId: account.providerId, providerAccountId: account.accountId }) + .from(account) + .where(eq(account.id, accountId)) + .limit(1) + if (!reconnected) return + const slackTeamId = isSlackProvider(reconnected.providerId) + ? extractSlackTeamId(reconnected.providerAccountId) + : null + const repairedAccounts = slackTeamId + ? inArray( + credential.accountId, + db.select({ id: account.id }).from(account).where(installationFilter(slackTeamId)) + ) + : eq(credential.accountId, accountId) + await db + .update(knowledgeConnector) + .set({ + status: 'active', + lastSyncError: null, + consecutiveFailures: 0, + nextSyncAt: now, + updatedAt: now, + }) + .where( + and( + inArray( + knowledgeConnector.credentialId, + db.select({ id: credential.id }).from(credential).where(repairedAccounts) + ), + eq(knowledgeConnector.status, 'error'), + eq(knowledgeConnector.lastSyncError, CREDENTIAL_REVOKED_SYNC_ERROR) + ) + ) +} diff --git a/apps/sim/lib/knowledge/connectors/sync-engine.test.ts b/apps/sim/lib/knowledge/connectors/sync-engine.test.ts index a0f8f97d284..fcdb6d43378 100644 --- a/apps/sim/lib/knowledge/connectors/sync-engine.test.ts +++ b/apps/sim/lib/knowledge/connectors/sync-engine.test.ts @@ -3,6 +3,7 @@ */ import { authOAuthUtilsMock, + authOAuthUtilsMockFns, dbChainMockFns, drizzleOrmMock, flattenMockConditions, @@ -17,6 +18,7 @@ import { DrizzleQueryError } from 'drizzle-orm/errors' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import * as connectorTokens from '@/lib/knowledge/connectors/access-token' import { executeSync, isConnectorRunnableStatus } from '@/lib/knowledge/connectors/sync-engine' +import { CREDENTIAL_REVOKED_SYNC_ERROR } from '@/lib/knowledge/connectors/sync-limits' import { classifySuspectListing, evaluateListingSafety, @@ -3007,6 +3009,131 @@ describe('executeSync heartbeats during the listing phase', () => { } ) + /** A locked OAuth connector whose token resolution the test controls. */ + function primeOAuthRunUpToToken() { + const oauthConnector = { + ...CONNECTOR, + connectorType: 'oauth', + credentialId: 'cred-1', + accessMode: 'workspace', + } + queueTableRows(schemaMock.knowledgeConnector, [oauthConnector]) + for (let i = 0; i < 20; i++) + queueTableRows(schemaMock.knowledgeConnector, [ + { id: 'c-1', connectorArchivedAt: null, connectorDeletedAt: null, kbDeletedAt: null }, + ]) + queueTableRows(schemaMock.knowledgeBase, [{ userId: 'u-1', workspaceId: 'ws-1' }]) + dbChainMockFns.returning.mockReset() + dbChainMockFns.returning.mockResolvedValueOnce([oauthConnector]) + /** The terminal write lands on the row this run still holds. */ + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'c-1' }]) + const tokenUser = vi + .spyOn(connectorTokens, 'resolveConnectorTokenUserId') + .mockResolvedValueOnce('u-1') + const resolveToken = vi + .spyOn(connectorTokens, 'resolveConnectorAccessToken') + .mockResolvedValueOnce(null) + return () => { + tokenUser.mockRestore() + resolveToken.mockRestore() + } + } + + it('unschedules a connector whose credential the source rejected instead of retrying it', async () => { + const restore = primeOAuthRunUpToToken() + /** Rejected at token resolution and still rejected when the run records its outcome. */ + authOAuthUtilsMockFns.mockGetCredentialTerminalRefreshError + .mockResolvedValueOnce('invalid_grant') + .mockResolvedValueOnce('invalid_grant') + try { + const result = await executeSync('c-1', { + billingAttribution: { workspaceId: 'ws-1' } as never, + }) + expect(result.skipReason).toBe('credential_revoked') + expect(result.error).toBeUndefined() + expect(authOAuthUtilsMockFns.mockGetCredentialTerminalRefreshError).toHaveBeenCalledWith( + 'cred-1' + ) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'error', + nextSyncAt: null, + lastSyncError: CREDENTIAL_REVOKED_SYNC_ERROR, + syncLockToken: null, + syncLockLeaseAt: null, + }) + ) + expect(dbChainMockFns.set).not.toHaveBeenCalledWith( + expect.objectContaining({ consecutiveFailures: expect.any(Number) }) + ) + } finally { + restore() + } + }) + + it('takes the failure ladder when the credential was reauthorized while the run was failing', async () => { + const restore = primeOAuthRunUpToToken() + /** Rejected at token resolution, repaired by the time the run records its outcome. */ + authOAuthUtilsMockFns.mockGetCredentialTerminalRefreshError + .mockResolvedValueOnce('invalid_grant') + .mockResolvedValueOnce(null) + try { + const result = await executeSync('c-1', { + billingAttribution: { workspaceId: 'ws-1' } as never, + }) + expect(result.skipReason).toBeUndefined() + expect(result.error).toContain('rejected by the source') + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ status: 'error', consecutiveFailures: 1 }) + ) + expect(dbChainMockFns.set).not.toHaveBeenCalledWith( + expect.objectContaining({ lastSyncError: CREDENTIAL_REVOKED_SYNC_ERROR }) + ) + } finally { + restore() + } + }) + + it('reports a run that could not record the unschedule as failed, not skipped', async () => { + const restore = primeOAuthRunUpToToken() + /** Rejected at token resolution and still rejected when the run records its outcome. */ + authOAuthUtilsMockFns.mockGetCredentialTerminalRefreshError + .mockResolvedValueOnce('invalid_grant') + .mockResolvedValueOnce('invalid_grant') + /** The terminal write fails after the lock CAS consumed the first result. */ + dbChainMockFns.returning.mockReset() + dbChainMockFns.returning.mockResolvedValueOnce([ + { ...CONNECTOR, connectorType: 'oauth', credentialId: 'cred-1', accessMode: 'workspace' }, + ]) + dbChainMockFns.returning.mockRejectedValueOnce(new Error('connection reset')) + try { + const result = await executeSync('c-1', { + billingAttribution: { workspaceId: 'ws-1' } as never, + }) + expect(result.skipReason).toBeUndefined() + expect(result.error).toContain('connection reset') + } finally { + restore() + } + }) + + it('keeps the failure ladder for a credential that resolved no token without a terminal error', async () => { + const restore = primeOAuthRunUpToToken() + authOAuthUtilsMockFns.mockGetCredentialTerminalRefreshError.mockResolvedValueOnce(null) + try { + const result = await executeSync('c-1', { + billingAttribution: { workspaceId: 'ws-1' } as never, + }) + expect(result.skipReason).toBeUndefined() + expect(result.error).toContain('Failed to obtain access token') + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ status: 'error', consecutiveFailures: 1 }) + ) + } finally { + restore() + } + }) + it.each([ { acl: undefined, incomplete: true }, { acl: ['invalid-token'], incomplete: true }, diff --git a/apps/sim/lib/knowledge/connectors/sync-engine.ts b/apps/sim/lib/knowledge/connectors/sync-engine.ts index d5f5b0dbde1..d8a429696a3 100644 --- a/apps/sim/lib/knowledge/connectors/sync-engine.ts +++ b/apps/sim/lib/knowledge/connectors/sync-engine.ts @@ -57,6 +57,7 @@ import { CONNECTOR_FAILURE_BACKOFF_CAP_MINUTES, CONNECTOR_SYNC_MAX_DURATION_SECONDS, CREDENTIAL_REMOVED_SYNC_ERROR, + CREDENTIAL_REVOKED_SYNC_ERROR, connectorFailureBackoffMinutes, MAX_CONSECUTIVE_FAILURES, } from '@/lib/knowledge/connectors/sync-limits' @@ -88,6 +89,7 @@ import { import { hardDeleteDocuments } from '@/lib/knowledge/documents/service' import { getRetryAfterMs, isRateLimitError } from '@/lib/knowledge/documents/utils' import { ensureSourceVectorIndex } from '@/lib/knowledge/search/source-vector-indexes' +import { getCredentialTerminalRefreshError } from '@/lib/oauth/credential-service' import { connectorHasAuthSource } from '@/connectors/auth' import { CONNECTOR_REGISTRY } from '@/connectors/registry.server' import type { @@ -743,9 +745,26 @@ export function buildSyncSuccessUpdate( } } +/** + * A credential the source rejected outright: the refresh path recorded a terminal error for + * it, so no retry can produce a token until the credential is reauthorized. + */ +export class ConnectorCredentialRevokedError extends Error { + constructor( + readonly credentialId: string, + readonly errorCode: string + ) { + super(`Credential ${credentialId} was rejected by the source (${errorCode})`) + this.name = 'ConnectorCredentialRevokedError' + } +} + /** * Resolves the token a connector syncs with, failing loudly where the shared * resolver reports "no token" — a sync has no reconnect prompt to fall back to. + * A credential the source has rejected outright fails as + * {@link ConnectorCredentialRevokedError}, so the run can unschedule the + * connector instead of walking the failure ladder toward a retry that cannot help. */ async function resolveAccessToken( connector: { credentialId: string | null; encryptedApiKey: string | null }, @@ -770,6 +789,13 @@ async function resolveAccessToken( userId, authMode: connectorConfig.auth.mode, }) + const terminalError = + connectorConfig.auth.mode === 'oauth' && connector.credentialId + ? await getCredentialTerminalRefreshError(connector.credentialId) + : null + if (terminalError && connector.credentialId) { + throw new ConnectorCredentialRevokedError(connector.credentialId, terminalError) + } throw new Error(`Failed to obtain access token for credential ${connector.credentialId}`) } @@ -1427,6 +1453,61 @@ export async function executeSync( } } + if (error instanceof ConnectorCredentialRevokedError) { + /** + * Retrying cannot help until the credential is reauthorized, so the + * connector leaves its schedule with a reconnect prompt instead of + * climbing the failure ladder toward the same rejection. Reauthorizing + * the credential puts it back on schedule, and a reauthorization that + * landed while this run was failing has already cleared the rejection: + * that run takes the ordinary ladder below, so its next attempt uses the + * repaired chain rather than leaving a repaired connector unscheduled. + * The unscheduled run itself is a skip: nothing about the source failed, + * and a sync that cannot start is not an incident to page on. A run that + * cannot record the unschedule is a failure, so the runner reports it + * instead of leaving the connector locked behind a benign outcome. + */ + const stillRejected = await getCredentialTerminalRefreshError(error.credentialId) + if (stillRejected) { + logger.warn('Sync unscheduled: the source rejected the connector credential', { + connectorId, + credentialId: error.credentialId, + errorCode: error.errorCode, + }) + try { + await completeSyncLog(syncLogId, 'failed', result, { + errorMessage: CREDENTIAL_REVOKED_SYNC_ERROR, + }) + const landed = await writeTerminalConnectorState( + connectorId, + syncLogId, + buildSyncUnscheduledUpdate(new Date(), CREDENTIAL_REVOKED_SYNC_ERROR) + ) + if (!landed) { + logger.warn( + 'Unschedule discarded — connector was reclaimed while this run was executing', + { connectorId, syncLogId } + ) + } + return { ...result, skipReason: 'credential_revoked' } + } catch (recoveryError) { + const recoveryMessage = + getConnectorFailureDiagnostic(recoveryError)?.message ?? + toError(recoveryError).message + logger.error('Failed to unschedule the connector', { + connectorId, + error: recoveryMessage, + }) + result.error = recoveryMessage + return result + } + } + logger.info('Credential reauthorized during the run; the retry uses the repaired chain', { + connectorId, + credentialId: error.credentialId, + }) + } + const diagnostic = getConnectorFailureDiagnostic(error) const errorMessage = diagnostic?.message ?? toError(error).message const retryAfterMs = getRetryAfterMs(error) diff --git a/apps/sim/lib/knowledge/connectors/sync-limits.ts b/apps/sim/lib/knowledge/connectors/sync-limits.ts index 5a2af1b0ab2..5843a917afd 100644 --- a/apps/sim/lib/knowledge/connectors/sync-limits.ts +++ b/apps/sim/lib/knowledge/connectors/sync-limits.ts @@ -25,6 +25,13 @@ export const MAX_CONSECUTIVE_FAILURES = 10 export const CREDENTIAL_REMOVED_SYNC_ERROR = 'Credential removed. Reconnect the connector to resume syncing.' +/** + * The error a connector carries once the source rejects its credential outright (a revoked or + * expired grant, not a passing failure); cleared by reauthorizing that credential. + */ +export const CREDENTIAL_REVOKED_SYNC_ERROR = + 'The source no longer accepts this credential. Reconnect it to resume syncing.' + /** * The error a connector carries once {@link MAX_CONSECUTIVE_FAILURES} disables it. * diff --git a/apps/sim/lib/mcp/application/managed-connections.test.ts b/apps/sim/lib/mcp/application/managed-connections.test.ts index 9a85b92e116..c88c162c115 100644 --- a/apps/sim/lib/mcp/application/managed-connections.test.ts +++ b/apps/sim/lib/mcp/application/managed-connections.test.ts @@ -1,7 +1,7 @@ /** @vitest-environment node */ import type { SessionPrincipal } from '@sim/auth/principal' import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' -import { eq } from 'drizzle-orm' +import { eq, inArray } from 'drizzle-orm' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ @@ -10,7 +10,8 @@ const mocks = vi.hoisted(() => ({ group: vi.fn(), workspace: vi.fn(), permission: vi.fn(), - requireAccess: vi.fn(), + scopedAvailable: vi.fn(), + policy: vi.fn(), })) vi.mock('@/lib/billing/core/workspace-access', () => ({ getWorkspaceOwnerSubscriptionAccess: mocks.billing, @@ -21,8 +22,11 @@ vi.mock('@/lib/credential-groups/availability', () => ({ vi.mock('@/lib/credential-groups/credentials', () => ({ loadScopedAccountsCredentialListContext: mocks.group, })) -vi.mock('@/lib/credential-groups/application/organization-workspace-access', () => ({ - requireOrganizationAccountsWorkspaceAccess: mocks.requireAccess, +vi.mock('@/lib/credential-groups/scoped-availability', () => ({ + isScopedCredentialGroupsAvailable: mocks.scopedAvailable, +})) +vi.mock('@/lib/resource-policies/repository', () => ({ + requireResourcePolicy: mocks.policy, })) vi.mock('@/lib/mcp/application/context', () => ({ resolveMcpWorkspaceContext: mocks.workspace })) vi.mock('@sim/platform-authz/workspace', () => ({ @@ -30,7 +34,10 @@ vi.mock('@sim/platform-authz/workspace', () => ({ resolveEffectiveWorkspacePermission: mocks.permission, })) -import { buildOrganizationAccountAccessPolicy } from '@/lib/credential-groups/application/workspace-access-policy' +import { + buildOrganizationAccountAccessPolicy, + organizationAccountAccessPolicyCodec, +} from '@/lib/credential-groups/application/workspace-access-policy' import { listManagedMcpConnectionsUseCase } from '@/lib/mcp/application/managed-connections' const principal: SessionPrincipal = { kind: 'session', userId: 'user-1', sessionId: 'session-1' } @@ -53,6 +60,7 @@ describe('managed MCP connection catalog', () => { resetDbChainMock() mocks.billing.mockResolvedValue({ organizationId: 'org-1' }) mocks.available.mockResolvedValue(true) + mocks.scopedAvailable.mockResolvedValue(true) mocks.group.mockResolvedValue({ credentialGroupId: 'group-1' }) mocks.workspace.mockResolvedValue({ workspaceId: 'workspace-1', @@ -61,11 +69,11 @@ describe('managed MCP connection catalog', () => { billedAccountUserId: 'owner-1', }) mocks.permission.mockResolvedValue('read') - mocks.requireAccess.mockResolvedValue( - buildOrganizationAccountAccessPolicy('group-1', [ + mocks.policy.mockResolvedValue({ + document: buildOrganizationAccountAccessPolicy('group-1', [ { workspaceId: 'workspace-1', access: { mode: 'all' } }, - ]) - ) + ]), + }) }) it('uses organization ownership and workspace access before exposing credential operations', async () => { @@ -78,11 +86,12 @@ describe('managed MCP connection catalog', () => { ]) const result = await listManagedMcpConnectionsUseCase.execute({ principal, input }) expect(mocks.group).toHaveBeenCalledWith({ kind: 'organization', organizationId: 'org-1' }) - expect(mocks.requireAccess).toHaveBeenCalledWith( + expect(mocks.policy).toHaveBeenCalledWith( expect.objectContaining({ organizationId: 'org-1', - credentialGroupId: 'group-1', - workspaceId: 'workspace-1', + resourceType: 'credential_group', + resourceId: 'group-1', + codec: organizationAccountAccessPolicyCodec, }) ) expect(eq).toHaveBeenCalledWith(schemaMock.credential.organizationId, 'org-1') @@ -95,14 +104,93 @@ describe('managed MCP connection catalog', () => { }) }) - it('denies revoked workspace access before reading credentials', async () => { - mocks.requireAccess.mockRejectedValue(new Error('Workspace access revoked')) + it('returns an empty catalog when organization connected accounts are not configured', async () => { + mocks.group.mockResolvedValue(null) + await expect(listManagedMcpConnectionsUseCase.execute({ principal, input })).resolves.toEqual({ + servers: [], + tools: [], + }) + expect(mocks.policy).not.toHaveBeenCalled() + expect(dbChainMockFns.from).not.toHaveBeenCalled() + }) + + it.each(['workspace', 'organization'])( + 'returns an empty catalog when %s availability is disabled', + async (scope) => { + const available = scope === 'workspace' ? mocks.available : mocks.scopedAvailable + available.mockResolvedValue(false) + await expect(listManagedMcpConnectionsUseCase.execute({ principal, input })).resolves.toEqual( + { + servers: [], + tools: [], + } + ) + expect(mocks.policy).not.toHaveBeenCalled() + expect(dbChainMockFns.from).not.toHaveBeenCalled() + } + ) + + it.each([ + { name: 'no grants', grants: [] }, + { + name: 'another workspace only', + grants: [{ workspaceId: 'other-workspace', access: { mode: 'all' as const } }], + }, + { + name: 'OAuth only', + grants: [ + { + workspaceId: input.workspaceId, + access: { mode: 'selected' as const, credentialTypes: ['oauth:gmail' as const] }, + }, + ], + }, + ])('returns an empty catalog without an MCP workspace grant: $name', async ({ grants }) => { + mocks.policy.mockResolvedValue({ + document: buildOrganizationAccountAccessPolicy('group-1', grants), + }) + await expect(listManagedMcpConnectionsUseCase.execute({ principal, input })).resolves.toEqual({ + servers: [], + tools: [], + }) + expect(dbChainMockFns.from).not.toHaveBeenCalled() + }) + + it('only queries connectors granted to this workspace', async () => { + mocks.policy.mockResolvedValue({ + document: buildOrganizationAccountAccessPolicy('group-1', [ + { + workspaceId: input.workspaceId, + access: { mode: 'selected', credentialTypes: ['mcp:fireflies'] }, + }, + ]), + }) + await listManagedMcpConnectionsUseCase.execute({ principal, input }) + expect(inArray).toHaveBeenCalledWith(schemaMock.mcpServers.managedConnectorId, ['fireflies']) + }) + + it('rejects callers without workspace access before checking catalog availability', async () => { + mocks.permission.mockResolvedValue(null) await expect(listManagedMcpConnectionsUseCase.execute({ principal, input })).rejects.toThrow( - 'revoked' + 'Insufficient workspace permissions' ) + expect(mocks.billing).not.toHaveBeenCalled() + expect(mocks.policy).not.toHaveBeenCalled() expect(dbChainMockFns.from).not.toHaveBeenCalled() }) + it.each(['scopedAvailable', 'policy'] as const)( + 'propagates %s failures before reading credentials', + async (dependency) => { + const error = new Error('Database unavailable') + mocks[dependency].mockRejectedValue(error) + await expect(listManagedMcpConnectionsUseCase.execute({ principal, input })).rejects.toBe( + error + ) + expect(dbChainMockFns.from).not.toHaveBeenCalled() + } + ) + it.each([ [Array.from({ length: 501 }, () => metadata), 'connection limit'], [[{ ...metadata, toolSnapshotBytes: 6 * 1024 * 1024 }], 'metadata limit'], diff --git a/apps/sim/lib/mcp/application/managed-connections.ts b/apps/sim/lib/mcp/application/managed-connections.ts index c3e26b82102..853bc1f5895 100644 --- a/apps/sim/lib/mcp/application/managed-connections.ts +++ b/apps/sim/lib/mcp/application/managed-connections.ts @@ -3,17 +3,21 @@ import { credential, credentialGroup, credentialGroupEnrollment, mcpServers } fr import { and, asc, eq, inArray, isNotNull, isNull, or, sql } from 'drizzle-orm' import { getWorkspaceOwnerSubscriptionAccess } from '@/lib/billing/core/workspace-access' import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' -import { requireOrganizationAccountsWorkspaceAccess } from '@/lib/credential-groups/application/organization-workspace-access' -import { organizationAccountPolicyAllowsWorkspace } from '@/lib/credential-groups/application/workspace-access-policy' +import { + organizationAccountAccessPolicyCodec, + organizationAccountPolicyAllowsWorkspace, +} from '@/lib/credential-groups/application/workspace-access-policy' import { isCredentialGroupsAvailable } from '@/lib/credential-groups/availability' import { loadScopedAccountsCredentialListContext } from '@/lib/credential-groups/credentials' import { getManagedMcpConnector, MANAGED_MCP_CONNECTOR_IDS, } from '@/lib/credential-groups/managed-mcp-connectors' +import { isScopedCredentialGroupsAvailable } from '@/lib/credential-groups/scoped-availability' import { resolveMcpWorkspaceContext } from '@/lib/mcp/application/context' import { mcpServerOperations } from '@/lib/mcp/application/operations' import type { McpToolSchema } from '@/lib/mcp/types' +import { requireResourcePolicy } from '@/lib/resource-policies/repository' const MAX_MANAGED_MCP_CONNECTIONS = 500 const MAX_MANAGED_MCP_CATALOG_BYTES = 5 * 1024 * 1024 @@ -52,13 +56,18 @@ export const listManagedMcpConnectionsUseCase = defineAuthorizedWorkspaceUseCase organizationId, }) if (!group) return { servers: [], tools: [] } - const policy = await requireOrganizationAccountsWorkspaceAccess({ - ...context, + if (!(await isScopedCredentialGroupsAvailable({ kind: 'organization', organizationId }))) { + return { servers: [], tools: [] } + } + const policy = await requireResourcePolicy({ organizationId, - credentialGroupId: group.credentialGroupId, + resourceType: 'credential_group', + resourceId: group.credentialGroupId, + codec: organizationAccountAccessPolicyCodec, }) + /** Catalogs omit unavailable credentials; execution still requires explicit workspace access. */ const allowedConnectorIds = MANAGED_MCP_CONNECTOR_IDS.filter((id) => - organizationAccountPolicyAllowsWorkspace(policy, context.workspaceId, `mcp:${id}`) + organizationAccountPolicyAllowsWorkspace(policy.document, context.workspaceId, `mcp:${id}`) ) if (!allowedConnectorIds.length) return { servers: [], tools: [] } const managedCatalogScope = () => diff --git a/apps/sim/lib/memory/agent-turn-session.ts b/apps/sim/lib/memory/agent-turn-session.ts index 090fe06f792..eef5d6d6750 100644 --- a/apps/sim/lib/memory/agent-turn-session.ts +++ b/apps/sim/lib/memory/agent-turn-session.ts @@ -6,6 +6,7 @@ import { isRecordLike } from '@sim/utils/object' import { truncate } from '@sim/utils/string' import { isFeatureEnabled } from '@/lib/core/config/feature-flags' import { decryptSecret } from '@/lib/core/security/encryption' +import { stringifyBoundedJson } from '@/lib/core/utils/bounded-json' import { bindDurableSecretProvenanceToValue, durableSecretProvenanceFromRegistry, @@ -24,7 +25,6 @@ import { } from '@/lib/memory/application/agent-turns' import { MEMORY_DELEGATION_AUDIENCE } from '@/lib/memory/application/authorization' import { getMemoryArtifactHandle } from '@/lib/memory/artifact-handle' -import { stringifyBoundedMemoryJson } from '@/lib/memory/bounded-json' import { decryptMemoryCheckpoint, encryptMemoryCheckpoint, @@ -534,7 +534,7 @@ export async function openAgentTurnSession( }, async prepareResult(result) { let requiresArtifact = - stringifyBoundedMemoryJson(result, MEMORY.MAX_MESSAGE_CONTENT_BYTES) === undefined + stringifyBoundedJson(result, MEMORY.MAX_MESSAGE_CONTENT_BYTES) === undefined let safeError: string | undefined try { const projected = await project(result.modelResponse) @@ -550,7 +550,7 @@ export async function openAgentTurnSession( modelResponse: { ...result.modelResponse, ...projected }, } requiresArtifact ||= - stringifyBoundedMemoryJson(prepared, MEMORY.MAX_MESSAGE_CONTENT_BYTES) === undefined + stringifyBoundedJson(prepared, MEMORY.MAX_MESSAGE_CONTENT_BYTES) === undefined requiresArtifact ||= JSON.stringify(prepared.modelResponse).length > MAX_ARTIFACT_PREVIEW_CHARS if (requiresArtifact) { diff --git a/apps/sim/lib/memory/artifacts.ts b/apps/sim/lib/memory/artifacts.ts index b6d757bb708..8f575dd9f00 100644 --- a/apps/sim/lib/memory/artifacts.ts +++ b/apps/sim/lib/memory/artifacts.ts @@ -2,13 +2,13 @@ import { dbFor } from '@sim/db' import { executionLargeValues, memory, memoryArtifact } from '@sim/db/schema' import { and, eq, isNull, sql } from 'drizzle-orm' import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' +import { stringifyBoundedJson } from '@/lib/core/utils/bounded-json' import { collectLargeValueReferenceKeys, registerLargeValueOwner, } from '@/lib/execution/payloads/large-value-metadata' import { isLargeValueRef, type LargeValueRef } from '@/lib/execution/payloads/large-value-ref' import { materializeLargeValueRef, storeLargeValue } from '@/lib/execution/payloads/store' -import { stringifyBoundedMemoryJson } from '@/lib/memory/bounded-json' export const MAX_MEMORY_ARTIFACT_BYTES = 8 * 1024 * 1024 export const MAX_MEMORY_ARTIFACT_STORED_BYTES = MAX_MEMORY_ARTIFACT_BYTES * 2 + 1024 @@ -71,7 +71,7 @@ function activeMemoryPredicate(scope: MemoryArtifactScope) { export async function storeMemoryArtifact( input: StoreMemoryArtifactInput ): Promise { - const json = stringifyBoundedMemoryJson(input.value, MAX_MEMORY_ARTIFACT_BYTES) + const json = stringifyBoundedJson(input.value, MAX_MEMORY_ARTIFACT_BYTES) if (json === undefined) return undefined const execDb = dbFor('exec') const [conversation] = await execDb @@ -183,9 +183,7 @@ export async function readMemoryArtifact(input: ReadMemoryArtifactInput): Promis const { decrypted } = await decryptSecret(envelope.encrypted, { logFailure: false }) if (Buffer.byteLength(decrypted, 'utf8') > MAX_MEMORY_ARTIFACT_BYTES) return undefined const value: unknown = JSON.parse(decrypted) - return stringifyBoundedMemoryJson(value, MAX_MEMORY_ARTIFACT_BYTES) === undefined - ? undefined - : value + return stringifyBoundedJson(value, MAX_MEMORY_ARTIFACT_BYTES) === undefined ? undefined : value } catch { return undefined } diff --git a/apps/sim/lib/memory/conversation-store.ts b/apps/sim/lib/memory/conversation-store.ts index cfea3e9d2a3..1a2c50330f1 100644 --- a/apps/sim/lib/memory/conversation-store.ts +++ b/apps/sim/lib/memory/conversation-store.ts @@ -3,6 +3,7 @@ import { agentMemoryTurn, memory, memoryItem, memorySecretProvenance } from '@si import { generateId } from '@sim/utils/id' import { and, asc, desc, eq, gt, inArray, isNull, lt, type SQLWrapper, sql } from 'drizzle-orm' import { OrchestrationError } from '@/lib/core/orchestration/types' +import { stringifyBoundedJson } from '@/lib/core/utils/bounded-json' import type { DbOrTx, DbTransaction } from '@/lib/db/types' import { type DurableSecretProvenance, @@ -10,7 +11,6 @@ import { hashDurableSecretProvenanceValue, mergeDurableSecretProvenance, } from '@/lib/execution/durable-secret-provenance' -import { stringifyBoundedMemoryJson } from '@/lib/memory/bounded-json' import { lockMemoryConversationInTx } from '@/lib/memory/locks' import { MAX_RICH_MEMORY_PAGE_BYTES, PlainMemoryReadBudget } from '@/lib/memory/read-budget' import { @@ -98,7 +98,7 @@ interface PlainMemoryWriteInput { /** Both storage versions persist the same admitted snapshot of each new history item. */ function captureMemoryItem(value: unknown): unknown { - const encoded = stringifyBoundedMemoryJson(value, MAX_MEMORY_ITEM_BYTES) + const encoded = stringifyBoundedJson(value, MAX_MEMORY_ITEM_BYTES) if (encoded === undefined) throw new OrchestrationError( 'payload_too_large', diff --git a/apps/sim/lib/memory/retrieval-prefix.ts b/apps/sim/lib/memory/retrieval-prefix.ts index 0a1ed888efe..3a9f66096ff 100644 --- a/apps/sim/lib/memory/retrieval-prefix.ts +++ b/apps/sim/lib/memory/retrieval-prefix.ts @@ -1,9 +1,9 @@ import { dbFor } from '@sim/db' import { memory, memorySecretProvenance } from '@sim/db/schema' import { and, eq, isNull, sql } from 'drizzle-orm' +import { stringifyBoundedJson } from '@/lib/core/utils/bounded-json' import type { DurableSecretProvenance } from '@/lib/execution/durable-secret-provenance' import type { MemoryArtifactScope } from '@/lib/memory/artifacts' -import { stringifyBoundedMemoryJson } from '@/lib/memory/bounded-json' import { readBoundMemorySecretProvenance } from '@/lib/memory/secret-provenance' export const MAX_MEMORY_RETRIEVAL_PREFIX_BYTES = 1024 * 1024 @@ -45,7 +45,7 @@ export async function readMemoryRetrievalPrefix( return { status: 'oversized' } if ( !Array.isArray(row.data) || - stringifyBoundedMemoryJson(row.data, MAX_MEMORY_RETRIEVAL_PREFIX_BYTES) === undefined + stringifyBoundedJson(row.data, MAX_MEMORY_RETRIEVAL_PREFIX_BYTES) === undefined ) return { status: 'unavailable' } const provenance = readBoundMemorySecretProvenance(row) diff --git a/apps/sim/lib/memory/retrieval.ts b/apps/sim/lib/memory/retrieval.ts index 8b369e06a9b..f2805b41e05 100644 --- a/apps/sim/lib/memory/retrieval.ts +++ b/apps/sim/lib/memory/retrieval.ts @@ -3,6 +3,7 @@ import { isRecordLike } from '@sim/utils/object' import { escapeRegExp } from '@sim/utils/string' import { z } from 'zod' import { OrchestrationError } from '@/lib/core/orchestration/types' +import { stringifyBoundedJson } from '@/lib/core/utils/bounded-json' import { type DurableSecretProvenance, EXACT_EMPTY_DURABLE_SECRET_PROVENANCE, @@ -17,7 +18,6 @@ import { type MemoryArtifactScope, readMemoryArtifactByHandle, } from '@/lib/memory/artifacts' -import { stringifyBoundedMemoryJson } from '@/lib/memory/bounded-json' import { readConversationItems } from '@/lib/memory/conversation-store' import { readMemoryRetrievalPrefix } from '@/lib/memory/retrieval-prefix' import type { ExecutionContext } from '@/executor/types' @@ -187,7 +187,7 @@ async function projectText( provenance: DurableSecretProvenance, provenanceValue: unknown ): Promise { - if (stringifyBoundedMemoryJson(value, MAX_MEMORY_ARTIFACT_BYTES) === undefined) return undefined + if (stringifyBoundedJson(value, MAX_MEMORY_ARTIFACT_BYTES) === undefined) return undefined const current = input.projection.resolvedSecretTraceRegistry const registry = current?.forkForToolCall() ?? new ResolvedSecretTraceRegistry([]) if (!(await importDurableSecretProvenance(registry, provenance, provenanceValue))) @@ -198,7 +198,7 @@ async function projectText( const safe = redaction?.enabled ? await redactObjectStrings(projected.value, { ...redaction, onFailure: 'throw' }) : projected.value - return stringifyBoundedMemoryJson(withOpaqueHandles(safe), MAX_MEMORY_ARTIFACT_BYTES) + return stringifyBoundedJson(withOpaqueHandles(safe), MAX_MEMORY_ARTIFACT_BYTES) } function textChunk(text: string, offset: number, args: MemoryRetrievalArguments) { diff --git a/apps/sim/lib/oauth/credential-service.test.ts b/apps/sim/lib/oauth/credential-service.test.ts index cf88960c4d9..7808e1ef8cb 100644 --- a/apps/sim/lib/oauth/credential-service.test.ts +++ b/apps/sim/lib/oauth/credential-service.test.ts @@ -54,6 +54,7 @@ vi.mock('@/lib/oauth/microsoft', () => ({ vi.mock('@/lib/oauth/oauth', () => ({ OAUTH_PROVIDERS: {}, refreshOAuthToken: mocks.refreshOAuthToken, + TOKEN_REFRESH_TIMEOUT_MS: 15_000, })) vi.mock('@/lib/oauth/quickbooks-client-config', () => ({ @@ -76,6 +77,7 @@ vi.mock('@/lib/oauth/terminal-errors', () => ({ })) import { + getCredentialTerminalRefreshError, getOAuthToken, getServiceAccountToken, refreshTokenIfNeeded, @@ -85,7 +87,9 @@ import { } from '@/lib/oauth/credential-service' import { isInstagramProvider, shouldProactivelyRefreshInstagramToken } from '@/lib/oauth/instagram' import { isMicrosoftProvider } from '@/lib/oauth/microsoft' +import { getOAuthRefreshCoordinationIdentity } from '@/lib/oauth/refresh-coordination' import { fanOutSlackTokenChain } from '@/lib/oauth/slack' +import { isTerminalRefreshError, markCredentialDead } from '@/lib/oauth/terminal-errors' import { GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/oauth/types' const RAW_CREDENTIAL_ID = 'credential-raw-secret-id' @@ -243,6 +247,7 @@ describe('resolveCredentialTokenBundle selector privacy', () => { environment: 'sandbox', webhookVerifierToken: 'verifier-token', }) + dbChainMockFns.returning.mockResolvedValue([{ id: RAW_ACCOUNT_ID }]) mocks.refreshOAuthToken.mockResolvedValue({ ok: true, accessToken: 'new-access-token', @@ -416,10 +421,13 @@ describe('OAuth access-token refresh headroom', () => { refreshToken: 'rotated-refresh-token', expiresIn: 3600, }) + /** The rotated write matches the row unless a test makes the chain move first. */ + dbChainMockFns.returning.mockResolvedValue([{ id: RAW_ACCOUNT_ID }]) }) afterEach(() => { vi.useRealTimers() + vi.mocked(isTerminalRefreshError).mockReturnValue(false) vi.mocked(isInstagramProvider).mockReturnValue(false) vi.mocked(shouldProactivelyRefreshInstagramToken).mockReturnValue(false) vi.mocked(isMicrosoftProvider).mockReturnValue(false) @@ -523,6 +531,83 @@ describe('OAuth access-token refresh headroom', () => { expect(mocks.refreshOAuthToken).not.toHaveBeenCalled() }) + it('sizes the lease and the follower wait past the provider timeout for every provider', async () => { + queueCredentialAccount(createOAuthAccount()) + await resolveCredentialTokenBundle(RAW_CREDENTIAL_ID, RAW_USER_ID, 'test') + expect(mocks.withLeaderLock).toHaveBeenCalledWith( + expect.objectContaining({ ttlSec: 30, maxWaitMs: 30_000 }) + ) + }) + + it('rotates the chain only from the refresh token the refresh started from', async () => { + queueCredentialAccount(createOAuthAccount()) + await expect( + resolveCredentialTokenBundle(RAW_CREDENTIAL_ID, RAW_USER_ID, 'test') + ).resolves.toEqual({ accessToken: 'refreshed-access-token' }) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ refreshToken: 'rotated-refresh-token' }) + ) + const guard = JSON.stringify(dbChainMockFns.where.mock.calls.at(-1)) + expect(guard).toContain('account.refreshToken') + expect(guard).toContain('original-refresh-token') + }) + + it('returns no token when the rotation write finds the account gone', async () => { + queueCredentialAccount(createOAuthAccount()) + dbChainMockFns.returning.mockResolvedValueOnce([]) + queueTableRows(account, []) + await expect( + resolveCredentialTokenBundle(RAW_CREDENTIAL_ID, RAW_USER_ID, 'test') + ).resolves.toBeNull() + expect(mocks.logger.warn).toHaveBeenCalledWith( + 'Rotation write found no account; the credential is gone', + expect.anything() + ) + }) + + it('does not flag a credential dead when a terminal failure follows a newer rotation', async () => { + queueCredentialAccount(createOAuthAccount()) + vi.mocked(isTerminalRefreshError).mockReturnValue(true) + mocks.refreshOAuthToken.mockResolvedValue({ ok: false, errorCode: 'invalid_grant' }) + queueTableRows(account, [ + { + ...createOAuthAccount(3_600_000), + accessToken: 'winner-token', + refreshToken: 'winner-refresh-token', + }, + ]) + await expect( + resolveCredentialTokenBundle(RAW_CREDENTIAL_ID, RAW_USER_ID, 'test') + ).resolves.toEqual({ accessToken: 'winner-token' }) + expect(markCredentialDead).not.toHaveBeenCalled() + }) + + it('flags a credential dead on a terminal failure when its chain did not move', async () => { + queueCredentialAccount(createOAuthAccount()) + vi.mocked(isTerminalRefreshError).mockReturnValue(true) + mocks.refreshOAuthToken.mockResolvedValue({ ok: false, errorCode: 'invalid_grant' }) + queueTableRows(account, [createOAuthAccount()]) + await expect( + resolveCredentialTokenBundle(RAW_CREDENTIAL_ID, RAW_USER_ID, 'test') + ).resolves.toBeNull() + expect(markCredentialDead).toHaveBeenCalledWith(expect.any(String), 'invalid_grant') + }) + + it('uses the stored chain when the rotation write loses to a newer one', async () => { + queueCredentialAccount(createOAuthAccount()) + /** Another writer rotated first: no row still holds the token this refresh started from. */ + dbChainMockFns.returning.mockResolvedValueOnce([]) + queueTableRows(account, [{ ...createOAuthAccount(3_600_000), accessToken: 'winner-token' }]) + await expect( + resolveCredentialTokenBundle(RAW_CREDENTIAL_ID, RAW_USER_ID, 'test') + ).resolves.toEqual({ accessToken: 'winner-token' }) + expect(mocks.refreshOAuthToken).toHaveBeenCalledTimes(1) + expect(mocks.logger.warn).toHaveBeenCalledWith( + 'Rotation write lost to a newer chain; using the stored token', + expect.anything() + ) + }) + it.each([ { remainingMs: 6_000, refresh: true }, { remainingMs: 300_000, refresh: true }, @@ -832,3 +917,44 @@ describe('Google service-account token minting', () => { expect(JSON.stringify(mocks.logger.error.mock.calls)).not.toContain('xxxx') }) }) + +describe('getCredentialTerminalRefreshError', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.getRecentTerminalError.mockResolvedValue(null) + }) + + it('reads the flag on the account the credential resolves to', async () => { + queueTableRows(credential, [ + { id: RAW_CREDENTIAL_ID, type: 'oauth', accountId: RAW_ACCOUNT_ID }, + ]) + queueTableRows(account, [{ providerId: 'confluence', providerAccountId: 'provider-subject' }]) + mocks.getRecentTerminalError.mockResolvedValueOnce('invalid_grant') + await expect(getCredentialTerminalRefreshError(RAW_CREDENTIAL_ID)).resolves.toBe( + 'invalid_grant' + ) + expect(mocks.getRecentTerminalError).toHaveBeenCalledWith( + getOAuthRefreshCoordinationIdentity(RAW_ACCOUNT_ID) + ) + }) + + it('reads a Slack credential on its installation, the scope its refresh is flagged under', async () => { + queueTableRows(credential, [ + { id: RAW_CREDENTIAL_ID, type: 'oauth', accountId: RAW_ACCOUNT_ID }, + ]) + queueTableRows(account, [{ providerId: 'slack', providerAccountId: 'TEXAMPLE-usr_U1' }]) + await getCredentialTerminalRefreshError(RAW_CREDENTIAL_ID) + expect(mocks.getRecentTerminalError).toHaveBeenCalledWith( + getOAuthRefreshCoordinationIdentity('slack:TEXAMPLE') + ) + }) + + it('reports nothing for a service account, which never refreshes a chain', async () => { + queueTableRows(credential, [ + { id: RAW_CREDENTIAL_ID, type: 'service_account', accountId: null }, + ]) + await expect(getCredentialTerminalRefreshError(RAW_CREDENTIAL_ID)).resolves.toBeNull() + expect(mocks.getRecentTerminalError).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/oauth/credential-service.ts b/apps/sim/lib/oauth/credential-service.ts index d14c4d3921a..b3d882052cf 100644 --- a/apps/sim/lib/oauth/credential-service.ts +++ b/apps/sim/lib/oauth/credential-service.ts @@ -36,7 +36,7 @@ import { isMicrosoftProvider, PROACTIVE_REFRESH_THRESHOLD_DAYS, } from '@/lib/oauth/microsoft' -import { refreshOAuthToken } from '@/lib/oauth/oauth' +import { refreshOAuthToken, TOKEN_REFRESH_TIMEOUT_MS } from '@/lib/oauth/oauth' import { decryptQuickBooksOAuthClientConfig } from '@/lib/oauth/quickbooks-client-config' import { getOAuthRefreshCoordinationIdentity } from '@/lib/oauth/refresh-coordination' import { @@ -872,18 +872,87 @@ function isOAuthAccessTokenExpiring( } /** - * Slack lock budgets sized past `TOKEN_REFRESH_TIMEOUT_MS` (15s) in - * lib/oauth/oauth.ts: installation-keyed locks make every sibling row's request - * a follower of one refresh, so the TTL covers the provider call plus generous - * headroom for the surrounding DB reads and the fan-out write, and followers - * poll for the lock's full lifetime so a slow-but-successful refresh is still - * observed rather than reported as a failure. These budgets are latency knobs, - * not correctness guarantees — chain integrity under lock expiry or unlocked - * concurrent writers is enforced by the version-guarded fan-out - * (`ifChainUnchangedSince` in lib/oauth/slack.ts). + * Lock budgets sized past the provider call: the lease covers + * {@link TOKEN_REFRESH_TIMEOUT_MS} plus headroom for the account read before it and + * the rotated write after it, so a leader still talking to a slow provider keeps its + * lease instead of letting a second leader start a competing rotation; and followers + * poll for the lease's full lifetime, so a slow-but-successful refresh is observed + * rather than reported as a failure. Both are latency knobs, not correctness + * guarantees: a lease is only ever a lease, and chain integrity under lock expiry or + * an unlocked writer is enforced at the write, which rotates a chain only from the + * refresh token it started from (`ifChainUnchangedSince` for a Slack installation). */ -const SLACK_LOCK_TTL_SEC = 30 -const SLACK_FOLLOWER_MAX_WAIT_MS = SLACK_LOCK_TTL_SEC * 1000 +const REFRESH_LOCK_HEADROOM_MS = 15_000 +const REFRESH_LOCK_TTL_SEC = Math.ceil((TOKEN_REFRESH_TIMEOUT_MS + REFRESH_LOCK_HEADROOM_MS) / 1000) +const REFRESH_FOLLOWER_MAX_WAIT_MS = REFRESH_LOCK_TTL_SEC * 1000 + +/** + * The raw scope one refresh coordinates on: the account row, or the installation for a Slack + * bot token, whose sibling rows all hold one chain. + */ +function refreshCoordinationScope( + accountId: string, + providerId: string, + providerAccountId: string | null | undefined +): string { + const slackTeamId = isSlackProvider(providerId) ? extractSlackTeamId(providerAccountId) : null + return slackTeamId ? `slack:${slackTeamId}` : accountId +} + +/** + * The terminal error the refresh path last recorded for a credential's account, if any. A + * refresh that the provider rejected outright (a revoked or expired grant) flags the account + * for an hour so nothing retries it; a caller that finds no token can read the flag to tell + * that outcome, which only reauthorizing resolves, from a passing failure worth retrying. + */ +export async function getCredentialTerminalRefreshError( + credentialId: string +): Promise { + const resolved = await resolveOAuthAccountId(credentialId) + if (!resolved || resolved.credentialType === 'service_account' || !resolved.accountId) return null + const [row] = await db + .select({ providerId: account.providerId, providerAccountId: account.accountId }) + .from(account) + .where(eq(account.id, resolved.accountId)) + .limit(1) + if (!row) return null + return getRecentTerminalError( + getOAuthRefreshCoordinationIdentity( + refreshCoordinationScope(resolved.accountId, row.providerId, row.providerAccountId) + ) + ) +} + +interface StoredChain { + accessToken: string | null + accessTokenExpiresAt: Date | null + refreshToken: string | null +} + +/** The chain an account row holds now, or nothing when the account is gone. */ +async function readStoredChain(accountId: string): Promise { + const [stored] = await db + .select({ + accessToken: account.accessToken, + accessTokenExpiresAt: account.accessTokenExpiresAt, + refreshToken: account.refreshToken, + }) + .from(account) + .where(eq(account.id, accountId)) + .limit(1) + return stored +} + +/** + * The stored access token when it can still serve a request, as a follower would take it: a + * chain another writer just rotated carries one, and a token that has already expired is no + * answer at all. + */ +function usableStoredToken(stored: StoredChain, providerId: string): string | null { + return stored.accessToken && !isOAuthAccessTokenExpiring(stored.accessTokenExpiresAt, providerId) + ? stored.accessToken + : null +} async function performCoalescedRefresh({ accountId, @@ -901,8 +970,9 @@ async function performCoalescedRefresh({ * dead-flagged, and written per installation rather than per row. */ const slackTeamId = isSlackProvider(providerId) ? extractSlackTeamId(providerAccountId) : null - const rawScopeKey = slackTeamId ? `slack:${slackTeamId}` : accountId - const scopeKey = getOAuthRefreshCoordinationIdentity(rawScopeKey) + const scopeKey = getOAuthRefreshCoordinationIdentity( + refreshCoordinationScope(accountId, providerId, providerAccountId) + ) const logContext = { ...(requestId ? { requestId } : {}), @@ -926,11 +996,8 @@ async function performCoalescedRefresh({ const refreshPromise = coalesceLocally(lockKey, () => withLeaderLock({ key: lockKey, - // Installation-keyed Slack locks gather followers from every sibling row, - // so their wait and the lock TTL must outlast the 15s provider timeout — - // the 3s/10s defaults would fail followers early and let a second leader - // start a concurrent rotation mid-refresh. - ...(slackTeamId ? { maxWaitMs: SLACK_FOLLOWER_MAX_WAIT_MS, ttlSec: SLACK_LOCK_TTL_SEC } : {}), + ttlSec: REFRESH_LOCK_TTL_SEC, + maxWaitMs: REFRESH_FOLLOWER_MAX_WAIT_MS, onLeader: async () => { try { let refreshTokenToUse = refreshToken @@ -981,18 +1048,25 @@ async function performCoalescedRefresh({ message: result.message, }) if (result.errorCode && isTerminalRefreshError(result.errorCode)) { - // A refresh that lost a race with a concurrent connect fails with - // a revoked/rotated-out token even though the installation just - // got a live chain — dead-flagging then would take down a healthy - // credential for an hour. + // A refresh that lost a race with a concurrent connect or a newer + // rotation fails with a revoked/rotated-out token even though the + // account just got a live chain — dead-flagging then would take + // down a healthy credential for an hour. if ( slackChainVersion && (await hasSlackChainMoved(slackTeamId!, slackChainVersion)) ) { logger.info('Skipping dead flag: Slack chain moved during refresh', logContext) - } else { - await markCredentialDead(scopeKey, result.errorCode) + return null } + if (!slackTeamId) { + const stored = await readStoredChain(accountId) + if (stored && stored.refreshToken !== refreshToken) { + logger.info('Skipping dead flag: chain moved during refresh', logContext) + return usableStoredToken(stored, providerId) + } + } + await markCredentialDead(scopeKey, result.errorCode) } return null } @@ -1027,7 +1101,33 @@ async function performCoalescedRefresh({ ) } - await db.update(account).set(updateData).where(eq(account.id, accountId)) + /** + * The chain is rotated only from the refresh token this refresh started from. + * A lease is not mutual exclusion: it can expire under a slow provider or a + * paused process while the leader is still running, and an unconditional + * write would then let this refresh overwrite a newer rotation with a chain + * the provider has already retired, which the next refresh pays for as + * `invalid_grant` and, under reuse detection, as a revoked grant. When no row + * matches, another writer rotated first: its chain is the live one, so this + * caller uses what is stored and never retries the provider. + */ + const rotated = await db + .update(account) + .set(updateData) + .where(and(eq(account.id, accountId), eq(account.refreshToken, refreshToken))) + .returning({ id: account.id }) + if (rotated.length === 0) { + const stored = await readStoredChain(accountId) + if (!stored) { + logger.warn('Rotation write found no account; the credential is gone', logContext) + return null + } + logger.warn( + 'Rotation write lost to a newer chain; using the stored token', + logContext + ) + return usableStoredToken(stored, providerId) + } } logger.info('Successfully refreshed access token', logContext) diff --git a/apps/sim/lib/oauth/github-installation-credential.test.ts b/apps/sim/lib/oauth/github-installation-credential.test.ts index 29657563997..c508f11d615 100644 --- a/apps/sim/lib/oauth/github-installation-credential.test.ts +++ b/apps/sim/lib/oauth/github-installation-credential.test.ts @@ -13,7 +13,11 @@ vi.mock('@/lib/oauth/github-installation', () => ({ parseGitHubInstallationBinding: mocks.parseBinding, resolveGitHubInstallationAccessToken: mocks.resolveToken, })) -vi.mock('@/lib/oauth/oauth', () => ({ OAUTH_PROVIDERS: {}, refreshOAuthToken: vi.fn() })) +vi.mock('@/lib/oauth/oauth', () => ({ + OAUTH_PROVIDERS: {}, + refreshOAuthToken: vi.fn(), + TOKEN_REFRESH_TIMEOUT_MS: 15_000, +})) import { resolveServiceAccountToken } from '@/lib/oauth/credential-service' import { GITHUB_INSTALLATION_PROVIDER_ID } from '@/lib/oauth/github-installation-types' diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index 8ab9ddcd9ba..73b983508be 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -2250,7 +2250,7 @@ function safeOAuthErrorCode(value: unknown, secrets: string[]): string | undefin * Without this bound a hung endpoint would wedge every joiner on that key until * the undici socket defaults (~5 min) gave up. */ -const TOKEN_REFRESH_TIMEOUT_MS = 15_000 +export const TOKEN_REFRESH_TIMEOUT_MS = 15_000 function parseOAuthResponse(responseText: string): unknown { try { diff --git a/apps/sim/lib/oauth/slack.ts b/apps/sim/lib/oauth/slack.ts index 9c69ed3c887..e5f149d99c3 100644 --- a/apps/sim/lib/oauth/slack.ts +++ b/apps/sim/lib/oauth/slack.ts @@ -30,7 +30,8 @@ export function extractSlackTeamId(externalAccountId: string | null | undefined) return match ? match[1] : null } -function installationFilter(teamId: string) { +/** The account rows of one Slack installation: every bot token row of the team. */ +export function installationFilter(teamId: string) { return and(eq(account.providerId, 'slack'), like(account.accountId, `${teamId}-%`)) } diff --git a/apps/sim/lib/pptx-renderer/core/viewer.ts b/apps/sim/lib/pptx-renderer/core/viewer.ts index bd24037a059..92df460b948 100644 --- a/apps/sim/lib/pptx-renderer/core/viewer.ts +++ b/apps/sim/lib/pptx-renderer/core/viewer.ts @@ -611,14 +611,14 @@ export class PptxViewer extends EventTarget { item.style.cssText = 'width: fit-content; margin: 0 auto 20px;' const wrapper = document.createElement('div') - wrapper.style.cssText = ` - width: ${displayWidth}px; - height: ${displayHeight}px; - box-shadow: 0 2px 8px rgba(0,0,0,0.15); - overflow: hidden; - position: relative; - background: #fff; - ` + Object.assign(wrapper.style, { + width: `${displayWidth}px`, + height: `${displayHeight}px`, + boxShadow: '0 2px 8px rgba(0,0,0,0.15)', + overflow: 'hidden', + position: 'relative', + background: 'var(--white)', + }) item.appendChild(wrapper) @@ -860,11 +860,14 @@ export class PptxViewer extends EventTarget { this.mountedSlides.add(this.currentSlide) const wrapper = document.createElement('div') - wrapper.style.cssText = ` - width: ${displayWidth}px; height: ${displayHeight}px; - margin: 0 auto; overflow: hidden; position: relative; - box-shadow: 0 2px 8px rgba(0,0,0,0.15); - ` + Object.assign(wrapper.style, { + width: `${displayWidth}px`, + height: `${displayHeight}px`, + margin: '0 auto', + overflow: 'hidden', + position: 'relative', + boxShadow: '0 2px 8px rgba(0,0,0,0.15)', + }) try { const handle = renderSlideInternal(this.presentation, slide, { diff --git a/apps/sim/lib/settings/application/workspace-section-access.test.ts b/apps/sim/lib/settings/application/workspace-section-access.test.ts index f48fc89e1ed..2ca53d86e43 100644 --- a/apps/sim/lib/settings/application/workspace-section-access.test.ts +++ b/apps/sim/lib/settings/application/workspace-section-access.test.ts @@ -48,9 +48,9 @@ vi.mock('@/components/settings/navigation', () => ({ billing: 'billing', 'connected-accounts': 'connected-accounts', 'access-control': 'access-control', - requests: 'requests', }, UNIFIED_TO_WORKSPACE_SECTION: { + requests: 'requests', secrets: 'secrets', forks: 'forks', 'custom-blocks': 'custom-blocks', @@ -305,25 +305,16 @@ describe('authorizeWorkspaceSettingsSection', () => { expect(mocks.isOrganizationOnEnterprisePlan).toHaveBeenCalledTimes(1) }) - it('opens organization request settings without querying Enterprise entitlement', async () => { + it('opens own requests for workspace members without organization administration or Enterprise entitlement', async () => { mocks.checkWorkspaceAccess.mockResolvedValue(ORGANIZATION_ACCESS) - mocks.isOrganizationOnEnterprisePlan.mockResolvedValue(false) + mocks.resolveWorkspaceNavigation.mockReturnValue([{ id: 'requests' }]) + mocks.canOpenOrganizationSettingsSection.mockResolvedValue(false) await expect(authorize('requests')).resolves.toEqual({ allowed: true }) - expect(mocks.canOpenOrganizationSettingsSection).toHaveBeenCalledWith( - 'organization-1', - 'viewer-1', - 'requests' - ) + expect(mocks.canOpenOrganizationSettingsSection).not.toHaveBeenCalled() expect(mocks.isOrganizationOnEnterprisePlan).not.toHaveBeenCalled() }) - it('rejects organization request settings for personal workspaces and non-admins', async () => { - await expect(authorize('requests')).resolves.toEqual({ - allowed: false, - disposition: 'redirect-general', - }) - mocks.checkWorkspaceAccess.mockResolvedValue(ORGANIZATION_ACCESS) - mocks.canOpenOrganizationSettingsSection.mockResolvedValue(false) + it('does not offer request settings in a personal workspace', async () => { await expect(authorize('requests')).resolves.toEqual({ allowed: false, disposition: 'redirect-general', diff --git a/apps/sim/lib/settings/application/workspace-section-access.ts b/apps/sim/lib/settings/application/workspace-section-access.ts index 04be2103895..6072a7541c0 100644 --- a/apps/sim/lib/settings/application/workspace-section-access.ts +++ b/apps/sim/lib/settings/application/workspace-section-access.ts @@ -41,6 +41,9 @@ async function authorizeWorkspaceSection( }, permission: NonNullable>['permission']> ): Promise { + if (section === 'requests' && !workspace.organizationId) { + return { allowed: false, disposition: 'redirect-general' } + } const [accessControl, forksAvailable, customBlocksAvailable] = await Promise.all([ workspaceSectionUsesPermissionConfig(section) ? resolveVerifiedUserAccessControlContext( diff --git a/apps/sim/lib/workflows/blocks/block-outputs.ts b/apps/sim/lib/workflows/blocks/block-outputs.ts index ed8f324b9e2..0a2aa28d3f4 100644 --- a/apps/sim/lib/workflows/blocks/block-outputs.ts +++ b/apps/sim/lib/workflows/blocks/block-outputs.ts @@ -3,7 +3,9 @@ import { extractFieldsFromSchema, parseResponseFormatSafely, } from '@/lib/core/utils/response-format' +import { getJevAnswerOutput } from '@/lib/workflows/blocks/jev-outputs' import { normalizeInputFormatValue } from '@/lib/workflows/input-format' +import { containsReference } from '@/lib/workflows/sanitization/references' import { classifyStartBlockType, StartBlockPath, @@ -23,6 +25,7 @@ import { type OutputFieldDefinition, } from '@/blocks/types' import { isHumanInTheLoopBlock } from '@/executor/constants' +import { isEvaluationModel } from '@/providers/models' import { getToolOutputsMetadata } from '@/tools/metadata-outputs' import { getTrigger, isTriggerValid } from '@/triggers' @@ -61,15 +64,19 @@ function evaluateOutputCondition( const fieldValue = subBlocks[condition.field]?.value + const deferred = + condition.allowReference && typeof fieldValue === 'string' && containsReference(fieldValue) let matches: boolean - if (Array.isArray(condition.value)) { + if (deferred) { + matches = true + } else if (Array.isArray(condition.value)) { // For array conditions, check if fieldValue is a valid primitive and included matches = isConditionPrimitive(fieldValue) && condition.value.includes(fieldValue) } else { matches = fieldValue === condition.value } - if (condition.not) { + if (condition.not && !deferred) { matches = !matches } @@ -428,6 +435,18 @@ export function getEffectiveBlockOutputs( const includeHidden = options?.includeHidden ?? false if (blockType === 'agent') { + const model = subBlocks?.model?.value + const mayEvaluate = + typeof model === 'string' && (isEvaluationModel(model) || containsReference(model)) + if (mayEvaluate) { + const outputs = getBlockOutputs('agent', subBlocks, false, { includeHidden }) + const answers = getJevAnswerOutput(subBlocks?.evaluationQuestions?.value) + return { + ...outputs, + ...(containsReference(model) ? getResponseFormatOutputs(subBlocks, 'agent') : undefined), + ...(answers ? { answers } : undefined), + } + } const responseFormatOutputs = getResponseFormatOutputs(subBlocks, 'agent') if (responseFormatOutputs) return responseFormatOutputs } @@ -542,9 +561,7 @@ function traverseOutputPath(outputs: OutputDefinition, pathParts: string[]): unk const currentObj = current as Record - if (part in currentObj) { - current = currentObj[part] - } else if ( + if ( 'type' in currentObj && (currentObj.type === 'object' || currentObj.type === 'json') && 'properties' in currentObj && @@ -575,6 +592,8 @@ function traverseOutputPath(outputs: OutputDefinition, pathParts: string[]): unk } else { return null } + } else if (part in currentObj) { + current = currentObj[part] } else { return null } diff --git a/apps/sim/lib/workflows/blocks/jev-outputs.ts b/apps/sim/lib/workflows/blocks/jev-outputs.ts new file mode 100644 index 00000000000..e2545e4bda1 --- /dev/null +++ b/apps/sim/lib/workflows/blocks/jev-outputs.ts @@ -0,0 +1,52 @@ +import { isRecordLike } from '@sim/utils/object' +import type { JevAnswer } from '@/providers/typesafe/types' +import type { OutputProperty } from '@/tools/types' + +const ANSWER_FIELDS = { + choice: { + choice: { type: 'string' }, + confidence: { type: 'number' }, + probabilities: { type: 'json' }, + type: { type: 'string' }, + }, + score: { + score: { type: 'number' }, + confidence: { type: 'number' }, + probabilities: { type: 'json' }, + legend: { type: 'json' }, + type: { type: 'string' }, + }, + noul: { + noul: { type: 'number' }, + type: { type: 'string' }, + }, +} satisfies { + [Type in JevAnswer['type']]: Record, OutputProperty> +} + +/** + * Describes answers known from the editor's questions without requiring resolved inputs. + * Keys containing reference syntax remain accessible through the whole answers object. + */ +export function getJevAnswerOutput( + questions: unknown +): (OutputProperty & { type: 'json' }) | undefined { + if (typeof questions === 'string') { + try { + questions = JSON.parse(questions) + } catch { + return undefined + } + } + if (!isRecordLike(questions)) return undefined + + const entries: Array<[string, OutputProperty]> = [] + for (const [id, question] of Object.entries(questions)) { + if (!/^[\w-]+$/.test(id) || !isRecordLike(question)) continue + const { type } = question + if (type !== 'choice' && type !== 'score' && type !== 'noul') continue + entries.push([id, { type: 'json', properties: ANSWER_FIELDS[type] }]) + } + + return entries.length > 0 ? { type: 'json', properties: Object.fromEntries(entries) } : undefined +} diff --git a/apps/sim/providers/anthropic/core.request.test.ts b/apps/sim/providers/anthropic/core.request.test.ts index 95376030b2a..bc1ba9f5341 100644 --- a/apps/sim/providers/anthropic/core.request.test.ts +++ b/apps/sim/providers/anthropic/core.request.test.ts @@ -296,12 +296,15 @@ describe('executeAnthropicProviderRequest forced tool use', () => { expect(payload.tool_choice).toEqual({ type: 'tool', name: 'publish' }) }) - it('drops forced tool_choice when the catalog model disables Force', async () => { - const { payload, warn } = await runWithForcedTool('claude-fable-5-1') - expect(payload.tools?.map((tool) => tool.name)).toEqual(['publish']) - expect(payload).not.toHaveProperty('tool_choice') - expect(warn).toHaveBeenCalledWith(expect.stringContaining('rejects forced tool_choice')) - }) + it.each(['claude-fable-5-1', 'claude-opus-5-5'])( + 'drops forced tool_choice when the catalog model disables Force (%s)', + async (model) => { + const { payload, warn } = await runWithForcedTool(model) + expect(payload.tools?.map((tool) => tool.name)).toEqual(['publish']) + expect(payload).not.toHaveProperty('tool_choice') + expect(warn).toHaveBeenCalledWith(expect.stringContaining('rejects forced tool_choice')) + } + ) }) describe('executeAnthropicProviderRequest native structured outputs', () => { diff --git a/apps/sim/providers/anthropic/core.thinking.test.ts b/apps/sim/providers/anthropic/core.thinking.test.ts index 38375a5929e..2dc1aebe5a7 100644 --- a/apps/sim/providers/anthropic/core.thinking.test.ts +++ b/apps/sim/providers/anthropic/core.thinking.test.ts @@ -16,6 +16,7 @@ describe('buildThinkingConfig', () => { 'claude-fable-5-1', 'claude-fable-5', 'claude-sonnet-5', + 'claude-opus-5-5', 'claude-opus-5', 'claude-opus-4-8', 'claude-opus-4-7', @@ -31,6 +32,7 @@ describe('buildThinkingConfig', () => { 'claude-fable-5-1', 'claude-fable-5', 'claude-sonnet-5', + 'claude-opus-5-5', 'claude-opus-5', 'claude-opus-4-8', 'claude-opus-4-7', diff --git a/apps/sim/providers/anthropic/core.ts b/apps/sim/providers/anthropic/core.ts index 625cb5dfd8d..0f84ef8c615 100644 --- a/apps/sim/providers/anthropic/core.ts +++ b/apps/sim/providers/anthropic/core.ts @@ -143,7 +143,7 @@ const ANTHROPIC_THINKING_OUTPUT_HEADROOM = 4096 /** * Checks if a model supports adaptive thinking (thinking.type: "adaptive"). - * Fable 5 and Fable 5.1 support ONLY adaptive thinking (always on; type: "disabled" is rejected). + * Fable 5, Fable 5.1, and Opus 5.5 support ONLY adaptive thinking (always on; type: "disabled" is rejected). * Sonnet 5 supports ONLY adaptive thinking (manual budget_tokens returns a 400 error). * Opus 5, Opus 4.8, and Opus 4.7 support ONLY adaptive thinking (no extended thinking / budget_tokens). * Opus 4.6 and Sonnet 4.6 support both extended and adaptive thinking — use adaptive. @@ -169,7 +169,7 @@ function supportsAdaptiveThinking(modelId: string): boolean { /** * Builds the thinking configuration for the Anthropic API based on model capabilities and level. * - * - Fable 5.1, Fable 5, Sonnet 5, Opus 5, Opus 4.8, Opus 4.7: Uses adaptive thinking only (no extended thinking support) + * - Fable 5.1, Fable 5, Sonnet 5, Opus 5.5, Opus 5, Opus 4.8, Opus 4.7: Uses adaptive thinking only (no extended thinking support) * - Opus 4.6, Sonnet 4.6: Uses adaptive thinking with effort parameter * - Other models: Uses budget_tokens-based extended thinking * diff --git a/apps/sim/providers/conversation-continuation.ts b/apps/sim/providers/conversation-continuation.ts index 92941e2092b..84e5ccb3135 100644 --- a/apps/sim/providers/conversation-continuation.ts +++ b/apps/sim/providers/conversation-continuation.ts @@ -140,6 +140,7 @@ export async function restoreConversationNativeMessages( isChatCompletionsEndpoint(request?.azureEndpoint || env.AZURE_OPENAI_ENDPOINT || '') ? 'chat-completions' : providerHistoryProtocols[providerId] + if (!protocol) throw new Error('Evaluation providers do not support conversation history') const restored: Message[] = [] for (const group of groupConversationMessages(messages)) { const first = group[0] diff --git a/apps/sim/providers/history-adapters.test.ts b/apps/sim/providers/history-adapters.test.ts index ec2ee7785b0..55494a1a61b 100644 --- a/apps/sim/providers/history-adapters.test.ts +++ b/apps/sim/providers/history-adapters.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import type { ConversationProtocol } from '@/lib/memory/conversation-types' import { providerHistoryAdapters, providerHistoryProtocols } from '@/providers/history-adapters' -import { PROVIDER_DEFINITIONS } from '@/providers/models' +import { isEvaluationModel, PROVIDER_DEFINITIONS } from '@/providers/models' const fixtures: Array<{ protocol: ConversationProtocol; value: unknown }> = [ { @@ -121,7 +121,14 @@ describe('canonical provider wire adapters', () => { expect(Object.keys(providerHistoryProtocols).sort()).toEqual( Object.keys(PROVIDER_DEFINITIONS).sort() ) - for (const protocol of Object.values(providerHistoryProtocols)) - expect(providerHistoryAdapters[protocol]).toBeDefined() + for (const [providerId, protocol] of Object.entries(providerHistoryProtocols)) { + if (protocol === null) { + expect( + PROVIDER_DEFINITIONS[providerId].models.every((model) => isEvaluationModel(model.id)) + ).toBe(true) + } else { + expect(providerHistoryAdapters[protocol]).toBeDefined() + } + } }) }) diff --git a/apps/sim/providers/history-adapters.ts b/apps/sim/providers/history-adapters.ts index 7c629841129..07c1e70a341 100644 --- a/apps/sim/providers/history-adapters.ts +++ b/apps/sim/providers/history-adapters.ts @@ -80,7 +80,7 @@ export const providerHistoryAdapters: Record capture('bedrock', value) }, } -export const providerHistoryProtocols: Record = { +export const providerHistoryProtocols: Record = { openai: 'responses', 'azure-openai': 'responses', anthropic: 'anthropic', @@ -93,6 +93,7 @@ export const providerHistoryProtocols: Record cerebras: 'chat-completions', groq: 'chat-completions', sakana: 'chat-completions', + typesafe: null, nvidia: 'chat-completions', meta: 'chat-completions', zai: 'chat-completions', diff --git a/apps/sim/providers/index.test.ts b/apps/sim/providers/index.test.ts index 9fa071ed27e..1129732b7e5 100644 --- a/apps/sim/providers/index.test.ts +++ b/apps/sim/providers/index.test.ts @@ -2078,3 +2078,112 @@ describe('executeProviderRequest — model level normalization', () => { expect(sentRequest().reasoningEffort).toBeUndefined() }) }) + +describe('native evaluation provider boundary', () => { + beforeEach(() => { + vi.clearAllMocks() + envFlagsMockFns.getCostMultiplier.mockReturnValue(2) + mockExecuteRequest.mockResolvedValue({ + content: '{"passed":true}', + model: 'jev-1.13.0', + answers: { passed: true }, + tokens: { input: 100, output: 10, total: 110 }, + }) + mockGetApiKeyWithBYOK.mockResolvedValue({ apiKey: 'resolved-typesafe-key', isBYOK: true }) + }) + + it.each([ + ['typesafe', { model: 'jev-1.13.0', messages: [{ role: 'user', content: 'Chat' }] }], + ['openai', { model: 'gpt-4o', evaluation: { state: 'Test', questions: {} } }], + ] satisfies Array<[string, ProviderRequest]>)( + 'rejects a mismatched %s request modality', + async (provider, request) => { + await expect(executeProviderRequest(provider, request)).rejects.toThrow( + 'same evaluation or chat modality' + ) + expect(mockExecuteRequest).not.toHaveBeenCalled() + } + ) + + it('resolves BYOK credentials and keeps evaluation answers without charging Sim credits', async () => { + const evaluation = { + state: 'Task complete', + questions: { passed: { type: 'noul', instructions: 'Passed?' } }, + } + const result = await executeProviderRequest('typesafe', { + model: 'jev-1.13.0', + apiKey: 'test-key', + workspaceId: 'test-workspace', + evaluation, + }) + expect(mockGetApiKeyWithBYOK).toHaveBeenCalledWith( + 'typesafe', + 'jev-1.13.0', + 'test-workspace', + 'test-key' + ) + expect(mockExecuteRequest).toHaveBeenCalledWith( + expect.objectContaining({ apiKey: 'resolved-typesafe-key', evaluation }) + ) + expect(result).toMatchObject({ + answers: { passed: true }, + tokens: { total: 110 }, + cost: { input: 0, output: 0, total: 0 }, + }) + }) + + it.each(['jev-latest', 'jev-1.13.0', 'jev-preview'])( + 'bills hosted %s using the resolved model price and shared multiplier once', + async (model) => { + mockGetApiKeyWithBYOK.mockResolvedValue({ apiKey: 'hosted-typesafe-key', isBYOK: false }) + const result = await executeProviderRequest('typesafe', { + model, + workspaceId: 'test-workspace', + evaluation: { + state: 'Task complete', + questions: { passed: { type: 'noul', instructions: 'Passed?' } }, + }, + }) + expect(mockExecuteRequest).toHaveBeenCalledWith( + expect.objectContaining({ apiKey: 'hosted-typesafe-key', isBYOK: false }) + ) + expect(result).toMatchObject({ + model: 'jev-1.13.0', + cost: { input: 0.0000084, output: 0, total: 0.0000084 }, + }) + } + ) + + it.each([false, true])('applies Jev streaming billing consistently, BYOK=%s', async (isBYOK) => { + mockGetApiKeyWithBYOK.mockResolvedValue({ apiKey: 'resolved-typesafe-key', isBYOK }) + const streaming: StreamingExecution = { + stream: new ReadableStream(), + execution: { + success: true, + output: { + content: '{"passed":{"type":"noul","noul":0.9}}', + answers: { passed: { type: 'noul', noul: 0.9 } }, + model: 'jev-1.13.0', + tokens: { input: 100, output: 10, total: 110 }, + cost: { input: 0.0000042, output: 0, total: 0.0000042 }, + }, + logs: [], + }, + } + mockExecuteRequest.mockResolvedValue(streaming) + await executeProviderRequest('typesafe', { + model: 'jev-latest', + workspaceId: 'test-workspace', + stream: true, + evaluation: { + state: 'Task complete', + questions: { passed: { type: 'noul', instructions: 'Passed?' } }, + }, + }) + expect(streaming.execution.output.cost).toMatchObject({ + input: isBYOK ? 0 : 0.0000084, + output: 0, + total: isBYOK ? 0 : 0.0000084, + }) + }) +}) diff --git a/apps/sim/providers/index.ts b/apps/sim/providers/index.ts index 19760cd7a01..c616100d449 100644 --- a/apps/sim/providers/index.ts +++ b/apps/sim/providers/index.ts @@ -35,7 +35,7 @@ import { attachLargeFileRemoteUrls, uploadLargeFilesToProvider, } from '@/providers/file-attachments.server' -import { isKnownModelId } from '@/providers/models' +import { isEvaluationModel, isKnownModelId } from '@/providers/models' import { getProviderExecutor } from '@/providers/registry' import { type ProviderRuntimeContext, @@ -251,6 +251,10 @@ export async function executeProviderRequest( throw new Error(`Provider ${providerId} does not implement executeRequest`) } + if (isEvaluationModel(request.model) !== Boolean(request.evaluation)) { + throw new Error('The selected model and request must use the same evaluation or chat modality') + } + let resolvedRequest = sanitizeRequest(request) let isBYOK = false diff --git a/apps/sim/providers/models.test.ts b/apps/sim/providers/models.test.ts index b638b56a7cc..54d5064cd87 100644 --- a/apps/sim/providers/models.test.ts +++ b/apps/sim/providers/models.test.ts @@ -142,6 +142,8 @@ describe('OpenAI provider definition', () => { describe('direct provider catalog additions', () => { it.each([ + ['gpt-6-sol', 'openai'], + ['gpt-6-luna', 'openai'], ['chat-latest', 'openai'], ['gpt-5.3-codex', 'openai'], ['gemini-3.7-flash', 'google'], @@ -165,7 +167,7 @@ describe('catalog featured model metadata', () => { }) describe('forced tool use capability', () => { - it.each(['claude-fable-5-1', 'CLAUDE-FABLE-5-1'])( + it.each(['claude-fable-5-1', 'CLAUDE-FABLE-5-1', 'claude-opus-5-5'])( 'disables Force while keeping Auto and None support for %s', (model) => { expect(getModelCapabilities(model)).toMatchObject({ @@ -209,6 +211,7 @@ describe('Anthropic provider definition', () => { anthropic.models.filter((model) => model.sunset?.status === 'legacy').map((model) => model.id) ).toEqual([ 'claude-fable-5', + 'claude-opus-5', 'claude-opus-4-8', 'claude-opus-4-7', 'claude-opus-4-6', diff --git a/apps/sim/providers/models.ts b/apps/sim/providers/models.ts index 21d4203639d..323012c690c 100644 --- a/apps/sim/providers/models.ts +++ b/apps/sim/providers/models.ts @@ -28,6 +28,7 @@ import { OpenRouterIcon, SakanaIcon, TogetherIcon, + TypeSafeIcon, VertexIcon, VllmIcon, xAIIcon, @@ -83,6 +84,8 @@ export interface ModelCapabilities { */ streamed?: ThinkingStreamVisibility } + /** Uses native state and questions instead of a conversational prompt. */ + evaluation?: boolean deepResearch?: boolean /** Whether this model supports conversation memory. Defaults to true if omitted. */ memory?: boolean @@ -167,6 +170,34 @@ export function getProviderFileAttachment(providerId: string): ProviderFileAttac } export const PROVIDER_DEFINITIONS: Record = { + typesafe: { + id: 'typesafe', + name: 'TypeSafe', + description: 'Jev evaluation models for classification, scoring, and agent decisions', + icon: TypeSafeIcon, + color: '#F386A1', + models: [ + { + id: 'jev-latest', + pricing: { input: 0.042, output: 0, updatedAt: '2026-09-22' }, + capabilities: { evaluation: true, memory: false }, + contextWindow: 64000, + }, + { + id: 'jev-1.13.0', + pricing: { input: 0.042, output: 0, updatedAt: '2026-09-22' }, + capabilities: { evaluation: true, memory: false }, + contextWindow: 64000, + }, + { + id: 'jev-preview', + pricing: { input: 0.042, output: 0, updatedAt: '2026-09-22' }, + capabilities: { evaluation: true, memory: false }, + contextWindow: 64000, + }, + ], + defaultModel: 'jev-latest', + }, fireworks: { id: 'fireworks', name: 'Fireworks', @@ -357,6 +388,63 @@ export const PROVIDER_DEFINITIONS: Record = { featured: true, recommended: true, }, + { + id: 'gpt-6-sol', + pricing: { + input: 2.0, + cachedInput: 0.2, + output: 10.0, + tiers: [ + { + aboveInputTokens: 272000, + input: 4.0, + cachedInput: 0.4, + output: 15.0, + }, + ], + updatedAt: '2026-09-22', + }, + capabilities: { + reasoningEffort: { + values: ['none', 'low', 'medium', 'high', 'xhigh', 'max'], + }, + verbosity: { + values: ['low', 'medium', 'high'], + }, + maxOutputTokens: 128000, + }, + contextWindow: 1050000, + releaseDate: '2026-09-22', + }, + { + id: 'gpt-6-luna', + pricing: { + input: 0.1, + cachedInput: 0.01, + output: 0.5, + tiers: [ + { + aboveInputTokens: 272000, + input: 0.2, + cachedInput: 0.02, + output: 0.75, + }, + ], + updatedAt: '2026-09-22', + }, + capabilities: { + reasoningEffort: { + values: ['none', 'low', 'medium', 'high', 'xhigh', 'max'], + }, + verbosity: { + values: ['low', 'medium', 'high'], + }, + maxOutputTokens: 128000, + }, + contextWindow: 1050000, + releaseDate: '2026-09-22', + speedOptimized: true, + }, // GPT-4.1 family { id: 'gpt-4.1', @@ -1004,6 +1092,29 @@ export const PROVIDER_DEFINITIONS: Record = { releaseDate: '2026-06-30', recommended: true, }, + { + id: 'claude-opus-5-5', + pricing: { + input: 4.0, + cachedInput: 0.2, + output: 20.0, + updatedAt: '2026-09-22', + }, + capabilities: { + forcedToolUse: false, + nativeStructuredOutputs: true, + maxOutputTokens: 128000, + promptCaching: { minimumCacheableTokens: 512 }, + thinking: { + levels: ['low', 'medium', 'high', 'xhigh', 'max'], + default: 'medium', + streamed: 'summary', + }, + }, + contextWindow: 1000000, + releaseDate: '2026-09-22', + recommended: true, + }, { id: 'claude-opus-5', pricing: { @@ -1024,7 +1135,7 @@ export const PROVIDER_DEFINITIONS: Record = { }, contextWindow: 1000000, releaseDate: '2026-07-24', - recommended: true, + sunset: { status: 'legacy' }, }, { id: 'claude-opus-4-8', @@ -5312,6 +5423,7 @@ export function getProvidersWithToolUsageControl(): string[] { export function getHostedModels(): string[] { return [ + ...getProviderModels('typesafe'), ...getProviderModels('openai'), ...getProviderModels('anthropic'), ...getProviderModels('google'), @@ -5745,6 +5857,17 @@ export function getThinkingStreamVisibility(modelId: string): ThinkingStreamVisi return null } +/** Models that consume native evaluation inputs in the Agent block. */ +export function getEvaluationModels(): string[] { + return Object.values(PROVIDER_DEFINITIONS).flatMap((provider) => + provider.models.filter((model) => model.capabilities.evaluation).map((model) => model.id) + ) +} + +export function isEvaluationModel(modelId: string): boolean { + return getModelCapabilities(modelId)?.evaluation === true +} + /** * Get all models that support deep research capability */ diff --git a/apps/sim/providers/openai/usage.test.ts b/apps/sim/providers/openai/usage.test.ts index 5fcb590c64d..243a4c79297 100644 --- a/apps/sim/providers/openai/usage.test.ts +++ b/apps/sim/providers/openai/usage.test.ts @@ -34,6 +34,32 @@ function responsesUsage(partial: Partial): ResponsesUsageT } describe('OpenAI usage aggregation', () => { + it.each([ + ['gpt-6-sol', 272_000, 0.414, 0.1], + ['gpt-6-sol', 272_001, 0.828004, 0.15], + ['gpt-6-luna', 272_000, 0.0207, 0.005], + ['gpt-6-luna', 272_001, 0.0414002, 0.0075], + ] as const)( + 'bills %s at %i prompt tokens using the full prompt to price cache reads, writes, and output', + (model, promptTokens, inputCost, outputCost) => { + const usage = createOpenAIUsageAccumulator() + addOpenAIUsage( + usage, + responsesUsage({ + promptTokens, + cachedTokens: 100_000, + cacheWriteTokens: 100_000, + completionTokens: 10_000, + }) + ) + + const cost = buildOpenAIUsageCost(model, usage) + expect(cost.input).toBeCloseTo(inputCost, 10) + expect(cost.output).toBeCloseTo(outputCost, 10) + expect(cost.total).toBeCloseTo(inputCost + outputCost, 10) + } + ) + it('matches plain list pricing when nothing was cached', () => { const usage = createOpenAIUsageAccumulator() addOpenAIUsage(usage, responsesUsage({ promptTokens: 12_345, completionTokens: 6_789 })) diff --git a/apps/sim/providers/registry.ts b/apps/sim/providers/registry.ts index a1d70caa046..0088ac0f2ac 100644 --- a/apps/sim/providers/registry.ts +++ b/apps/sim/providers/registry.ts @@ -21,6 +21,7 @@ import { openRouterProvider } from '@/providers/openrouter' import { sakanaProvider } from '@/providers/sakana' import { togetherProvider } from '@/providers/together' import type { ProviderConfig, ProviderId } from '@/providers/types' +import { typesafeProvider } from '@/providers/typesafe' import { vertexProvider } from '@/providers/vertex' import { vllmProvider } from '@/providers/vllm' import { xAIProvider } from '@/providers/xai' @@ -39,6 +40,7 @@ const providerRegistry: Record = { cerebras: cerebrasProvider, groq: groqProvider, sakana: sakanaProvider, + typesafe: typesafeProvider, nvidia: nvidiaProvider, meta: metaProvider, zai: zaiProvider, diff --git a/apps/sim/providers/types.ts b/apps/sim/providers/types.ts index 43d8898058c..2cda6e7377c 100644 --- a/apps/sim/providers/types.ts +++ b/apps/sim/providers/types.ts @@ -14,6 +14,7 @@ export type ProviderId = | 'cerebras' | 'groq' | 'sakana' + | 'typesafe' | 'nvidia' | 'meta' | 'zai' @@ -93,6 +94,8 @@ export type TimeSegment = ProviderTimingSegment export interface ProviderResponse { content: string + /** Structured answers returned by a native evaluation model. */ + answers?: Record model: string tokens?: { /** Tokens billed at the base input rate, excluding cache reads and writes. */ @@ -191,7 +194,14 @@ export interface Message { tool_call_id?: string } +/** Native evaluation values are validated against the selected provider's schema. */ +export interface EvaluationInput { + state: unknown + questions: unknown +} + export interface ProviderRequest { + evaluation?: EvaluationInput /** Server-installed stable identity resolver; never accepted from an API payload. */ resolveToolInvocationId?: ( providerCallId: string | undefined, diff --git a/apps/sim/providers/typesafe/index.ts b/apps/sim/providers/typesafe/index.ts new file mode 100644 index 00000000000..044cb3f3abb --- /dev/null +++ b/apps/sim/providers/typesafe/index.ts @@ -0,0 +1,97 @@ +import type { StreamingExecution } from '@/executor/types' +import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { createSettledAgentEventStream } from '@/providers/stream-events' +import { createStreamingExecution } from '@/providers/streaming-execution' +import type { ProviderConfig, ProviderRequest, ProviderResponse } from '@/providers/types' +import { buildJevBody, parseJevResponse } from '@/providers/typesafe/schema' +import { requestJevEvaluation } from '@/providers/typesafe/transport' +import { calculateCost } from '@/providers/utils' + +export const typesafeProvider: ProviderConfig = { + id: 'typesafe', + name: 'TypeSafe', + description: 'Jev native evaluation models', + version: '1.0.0', + models: getProviderModels('typesafe'), + defaultModel: getProviderDefaultModel('typesafe'), + + async executeRequest(request: ProviderRequest): Promise { + if (!request.apiKey) throw new Error('API key is required for TypeSafe') + if (!request.evaluation) throw new Error('Jev requires evaluation state and questions') + if ( + request.messages?.length || + request.systemPrompt || + request.context || + request.tools?.length || + request.responseFormat || + request.previousInteractionId + ) { + throw new Error( + 'Jev accepts evaluation state and questions, not chat messages, tools, or response formats' + ) + } + + const body = buildJevBody( + { model: request.model, state: request.evaluation.state }, + request.evaluation.questions + ) + const start = Date.now() + const startTime = new Date(start).toISOString() + const result = parseJevResponse( + await requestJevEvaluation(body, request.apiKey, request.abortSignal), + body.questions + ) + const content = JSON.stringify(result.answers) + const tokens = { + input: result.usage.input_tokens, + output: result.usage.output_tokens, + total: result.usage.input_tokens + result.usage.output_tokens, + } + const cost = calculateCost(request.model, tokens.input, tokens.output) + if (request.stream) { + return createStreamingExecution({ + model: result.model, + providerStartTime: start, + providerStartTimeISO: startTime, + timing: { kind: 'simple', segmentName: result.model }, + initialTokens: tokens, + initialCost: cost, + isStreaming: true, + streamFormat: 'agent-events-v1', + createStream: ({ output, finalizeTiming }) => { + output.content = content + output.answers = result.answers + finalizeTiming() + return createSettledAgentEventStream(content) + }, + }) + } + const end = Date.now() + return { + content, + answers: result.answers, + model: result.model, + tokens, + cost, + timing: { + startTime, + endTime: new Date(end).toISOString(), + duration: end - start, + modelTime: end - start, + toolsTime: 0, + iterations: 1, + timeSegments: [ + { + type: 'model', + name: result.model, + startTime: start, + endTime: end, + duration: end - start, + tokens, + cost, + }, + ], + }, + } + }, +} diff --git a/apps/sim/providers/typesafe/schema.ts b/apps/sim/providers/typesafe/schema.ts new file mode 100644 index 00000000000..22f5fe5aae8 --- /dev/null +++ b/apps/sim/providers/typesafe/schema.ts @@ -0,0 +1,127 @@ +import { z } from 'zod' +import type { JevContent, JevEvaluationResult, JevQuestion } from '@/providers/typesafe/types' + +const contentSchema: z.ZodType = z.union([ + z.string(), + z.array(z.json()), + z.record(z.string(), z.json()), +]) + +const questionSchema: z.ZodType = z.discriminatedUnion('type', [ + z.object({ + type: z.literal('choice'), + instructions: contentSchema, + criteria: z + .record(z.string(), contentSchema.nullable()) + .refine( + (criteria) => Object.keys(criteria).length >= 1 && Object.keys(criteria).length <= 255, + 'Choice criteria must contain between 1 and 255 options' + ), + }), + z.object({ + type: z.literal('score'), + instructions: contentSchema, + criteria: z.array(contentSchema).min(2).max(10), + }), + z.object({ + type: z.literal('noul'), + instructions: contentSchema, + criteria: z + .object({ true: contentSchema.optional(), false: contentSchema.optional() }) + .strict() + .optional(), + }), +]) + +const questionsSchema = z + .record(z.string(), questionSchema) + .refine((questions) => Object.keys(questions).length > 0, 'Provide at least one question') + +const probabilitySchema = z.number().min(0).max(1) +const probabilitiesSchema = z.record(z.string(), probabilitySchema) +const responseSchema: z.ZodType = z.object({ + model: z.string(), + answers: z.record( + z.string(), + z.discriminatedUnion('type', [ + z.object({ + type: z.literal('choice'), + choice: z.string(), + probabilities: probabilitiesSchema, + confidence: probabilitySchema, + }), + z.object({ + type: z.literal('score'), + score: z.number(), + legend: z.record(z.string(), contentSchema), + probabilities: probabilitiesSchema, + confidence: probabilitySchema, + }), + z.object({ type: z.literal('noul'), noul: probabilitySchema }), + ]) + ), + usage: z.object({ + input_tokens: z.number().int().nonnegative(), + output_tokens: z.number().int().nonnegative(), + }), +}) + +export function parseJevJson(value: unknown, field: string): unknown { + if (typeof value !== 'string') return value + try { + return JSON.parse(value) + } catch { + throw new Error(`Jev ${field} must be valid JSON`) + } +} + +export function parseJevQuestions(questions: unknown) { + const parsedQuestions = questionsSchema.safeParse(parseJevJson(questions, 'questions')) + if (!parsedQuestions.success) { + throw new Error( + 'Invalid Jev questions: provide typed questions with instructions, 1–255 Choice options, 2–10 Score levels, or optional true/false Noul criteria' + ) + } + return parsedQuestions.data +} + +export function buildJevBody(params: { model: string; state: unknown }, questions: unknown) { + const parsedQuestions = parseJevQuestions(questions) + const state = contentSchema.safeParse(params.state) + if (!state.success) throw new Error('Jev state must be text, a JSON object, or an array') + return { + model: params.model, + state: state.data, + questions: parsedQuestions, + } +} + +export function parseJevResponse( + value: unknown, + questions: Record +): JevEvaluationResult { + const result = responseSchema.safeParse(value) + if (!result.success) throw new Error('TypeSafe returned an invalid Jev evaluation response') + const { answers } = result.data + if ( + Object.keys(answers).length !== Object.keys(questions).length || + Object.entries(questions).some( + ([id, question]) => !Object.hasOwn(answers, id) || answers[id].type !== question.type + ) + ) { + throw new Error( + 'TypeSafe returned Jev answers that do not match the requested question IDs and types' + ) + } + for (const [id, question] of Object.entries(questions)) { + const answer = answers[id] + if ( + question.type === 'choice' && + answer.type === 'choice' && + !Object.hasOwn(question.criteria, answer.choice) + ) { + throw new Error('TypeSafe returned a Jev Choice answer outside the requested options') + } + } + return result.data +} diff --git a/apps/sim/providers/typesafe/transport.ts b/apps/sim/providers/typesafe/transport.ts new file mode 100644 index 00000000000..137caf295ad --- /dev/null +++ b/apps/sim/providers/typesafe/transport.ts @@ -0,0 +1,67 @@ +import { interruptibleSleep } from '@sim/utils/helpers' +import { backoffWithJitter, parseRetryAfter } from '@sim/utils/retry' +import { stringifyBoundedJson } from '@/lib/core/utils/bounded-json' +import { consumeOrCancelBody, readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits' +import { PROVIDER_HEADERS_TIMEOUT_MS, PROVIDER_MAX_RETRIES } from '@/providers/transport' +import type { buildJevBody } from '@/providers/typesafe/schema' + +const MAX_EVALUATION_RESPONSE_BYTES = 10 * 1024 * 1024 +export const MAX_EVALUATION_REQUEST_BYTES = 10 * 1024 * 1024 + +class TypeSafeHttpError extends Error { + constructor( + readonly status: number, + readonly retryAfterMs: number | null + ) { + super(`TypeSafe evaluation failed (HTTP ${status})`) + this.name = 'TypeSafeHttpError' + } +} + +export async function requestJevEvaluation( + body: ReturnType, + apiKey: string, + abortSignal?: AbortSignal +): Promise { + const payload = stringifyBoundedJson(body, MAX_EVALUATION_REQUEST_BYTES) + if (payload === undefined) { + throw new Error('TypeSafe evaluation request exceeds the size or JSON complexity limit') + } + for (let attempt = 0; ; attempt++) { + abortSignal?.throwIfAborted() + const timeout = AbortSignal.timeout(PROVIDER_HEADERS_TIMEOUT_MS) + const signal = abortSignal ? AbortSignal.any([abortSignal, timeout]) : timeout + let response: Response | undefined + try { + signal.throwIfAborted() + response = await fetch('https://api.typesafe.ai/v1/systemone', { + method: 'POST', + headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' }, + body: payload, + signal, + redirect: 'error', + }) + if (!response.ok) { + await consumeOrCancelBody(response) + throw new TypeSafeHttpError( + response.status, + parseRetryAfter(response.headers.get('retry-after')) + ) + } + return await readResponseJsonWithLimit(response, { + maxBytes: MAX_EVALUATION_RESPONSE_BYTES, + label: 'TypeSafe evaluation response', + signal, + }) + } catch (error) { + abortSignal?.throwIfAborted() + const retryable = + error instanceof TypeSafeHttpError + ? error.status === 408 || error.status === 429 || error.status >= 500 + : !response || timeout.aborted || error instanceof TypeError + if (!retryable || attempt >= PROVIDER_MAX_RETRIES) throw error + const retryAfterMs = error instanceof TypeSafeHttpError ? error.retryAfterMs : null + await interruptibleSleep(backoffWithJitter(attempt + 1, retryAfterMs), abortSignal) + } + } +} diff --git a/apps/sim/providers/typesafe/types.ts b/apps/sim/providers/typesafe/types.ts new file mode 100644 index 00000000000..6167f35fe47 --- /dev/null +++ b/apps/sim/providers/typesafe/types.ts @@ -0,0 +1,58 @@ +export type JevJsonValue = + | string + | number + | boolean + | null + | JevJsonValue[] + | { [key: string]: JevJsonValue } + +export type JevContent = string | JevJsonValue[] | { [key: string]: JevJsonValue } + +export type JevQuestion = + | { + type: 'choice' + instructions: JevContent + criteria: Record + } + | { type: 'score'; instructions: JevContent; criteria: JevContent[] } + | { + type: 'noul' + instructions: JevContent + criteria?: { true?: JevContent; false?: JevContent } + } + +export interface JevChoiceAnswer { + type: 'choice' + choice: string + probabilities: Record + confidence: number +} + +export interface JevScoreAnswer { + type: 'score' + score: number + legend: Record + probabilities: Record + confidence: number +} + +export interface JevNoulAnswer { + type: 'noul' + noul: number +} + +export type JevAnswer = JevChoiceAnswer | JevScoreAnswer | JevNoulAnswer + +export interface JevUsage { + input_tokens: number + output_tokens: number +} + +export interface JevResponseMetadata { + model: string + usage: JevUsage +} + +export interface JevEvaluationResult extends JevResponseMetadata { + answers: Record +} diff --git a/apps/sim/providers/typesafe/typesafe.test.ts b/apps/sim/providers/typesafe/typesafe.test.ts new file mode 100644 index 00000000000..e8f449125ff --- /dev/null +++ b/apps/sim/providers/typesafe/typesafe.test.ts @@ -0,0 +1,343 @@ +/** @vitest-environment node */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + getHostedModels, + getModelCapabilities, + getProviderDefaultModel, + getProviderIcon, + getProviderModels, +} from '@/providers/models' +import { PROVIDER_MAX_RETRIES } from '@/providers/transport' +import type { ProviderRequest } from '@/providers/types' +import { typesafeProvider } from '@/providers/typesafe' +import { buildJevBody, parseJevResponse } from '@/providers/typesafe/schema' +import { MAX_EVALUATION_REQUEST_BYTES, requestJevEvaluation } from '@/providers/typesafe/transport' +import type { JevEvaluationResult, JevQuestion } from '@/providers/typesafe/types' +import { getProviderFromModel, shouldBillModelUsage } from '@/providers/utils' + +const QUESTIONS: Record = { + department: { + type: 'choice', + instructions: 'Which team?', + criteria: { billing: null, technical: 'Bugs' }, + }, + frustration: { + type: 'score', + instructions: 'How frustrated?', + criteria: ['Calm', 'Frustrated', 'Angry'], + }, + urgent: { type: 'noul', instructions: 'Is this urgent?' }, +} +const RESULT: JevEvaluationResult = { + model: 'jev-1.13.0', + answers: { + department: { + type: 'choice', + choice: 'billing', + probabilities: { billing: 0.88, technical: 0.12 }, + confidence: 0.81, + }, + frustration: { + type: 'score', + score: 1.05, + legend: { '0': 'Calm', '1': 'Frustrated', '2': 'Angry' }, + probabilities: { '0': 0, '1': 0.95, '2': 0.05 }, + confidence: 0.92, + }, + urgent: { type: 'noul', noul: 0.95 }, + }, + usage: { input_tokens: 318, output_tokens: 34 }, +} +const REQUEST: ProviderRequest = { + model: 'jev-1.13.0', + apiKey: 'test-key', + evaluation: { state: 'My payouts have been failing for three days.', questions: QUESTIONS }, +} +const fetchMock = vi.fn() + +describe('TypeSafe provider', () => { + beforeEach(() => { + fetchMock.mockReset().mockResolvedValue(Response.json(RESULT)) + vi.stubGlobal('fetch', fetchMock) + }) + afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() + vi.useRealTimers() + }) + + it.each(['jev-1.13.0', 'jev-latest', 'jev-preview'])( + 'routes hosted-capable %s through native evaluation', + async (model) => { + expect(getProviderFromModel(model)).toBe('typesafe') + expect(getHostedModels()).toContain(model) + expect(shouldBillModelUsage(model)).toBe(true) + expect(getModelCapabilities(model)).toMatchObject({ evaluation: true, memory: false }) + expect(getProviderIcon(model)).toBeDefined() + const result = await typesafeProvider.executeRequest({ ...REQUEST, model }) + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.typesafe.ai/v1/systemone', + expect.objectContaining({ + method: 'POST', + redirect: 'error', + headers: { Authorization: 'Bearer test-key', 'Content-Type': 'application/json' }, + body: JSON.stringify({ model, state: REQUEST.evaluation?.state, questions: QUESTIONS }), + }) + ) + expect(result).toMatchObject({ + content: JSON.stringify(RESULT.answers), + answers: RESULT.answers, + model: RESULT.model, + tokens: { input: 318, output: 34, total: 352 }, + timing: { iterations: 1, toolsTime: 0 }, + }) + } + ) + + it('defaults to the stable alias while retaining the pinned model', () => { + expect(getProviderDefaultModel('typesafe')).toBe('jev-latest') + expect(getProviderModels('typesafe')[0]).toBe('jev-latest') + expect(getProviderModels('typesafe')).toContain('jev-1.13.0') + }) + + it.each(['42', 'false', 'null', { text: 'Refund required' }, ['first', { second: true }]])( + 'preserves native state %j', + async (state) => { + await typesafeProvider.executeRequest({ + ...REQUEST, + evaluation: { state, questions: JSON.stringify(QUESTIONS) }, + }) + expect(JSON.parse(String(fetchMock.mock.calls[0][1]?.body)).state).toEqual(state) + } + ) + + it('delivers complete structured answers to streaming consumers', async () => { + const result = await typesafeProvider.executeRequest({ ...REQUEST, stream: true }) + if (!('execution' in result)) throw new Error('Expected streaming execution') + expect(result.execution.output).toMatchObject({ + answers: RESULT.answers, + content: JSON.stringify(RESULT.answers), + tokens: { total: 352 }, + }) + const reader = result.stream.getReader() + const events: unknown[] = [] + for (;;) { + const next = await reader.read() + if (next.done) break + events.push(next.value) + } + expect(JSON.stringify(events)).toContain('billing') + }) + + it.each([ + { apiKey: undefined }, + { evaluation: undefined }, + { messages: [{ role: 'user', content: 'Chat' }] }, + { responseFormat: { name: 'response', schema: {} } }, + ] satisfies Partial[])( + 'rejects incomplete or conversational requests before sending them', + async (override) => { + await expect(typesafeProvider.executeRequest({ ...REQUEST, ...override })).rejects.toThrow() + expect(fetchMock).not.toHaveBeenCalled() + } + ) + + it('does not echo upstream error bodies or credentials', async () => { + fetchMock.mockResolvedValue( + Response.json({ error: 'private provider context test-key' }, { status: 401 }) + ) + await expect(typesafeProvider.executeRequest(REQUEST)).rejects.toThrow( + 'TypeSafe evaluation failed (HTTP 401)' + ) + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it.each([408, 429, 500, 503])('retries HTTP %s and honors Retry-After', async (status) => { + vi.useFakeTimers() + fetchMock.mockResolvedValueOnce(new Response(null, { status, headers: { 'retry-after': '2' } })) + const result = typesafeProvider.executeRequest(REQUEST) + await vi.advanceTimersByTimeAsync(1999) + expect(fetchMock).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1) + expect(await result).toMatchObject({ answers: RESULT.answers }) + expect(fetchMock).toHaveBeenCalledTimes(2) + }) + + it.each([400, 403, 422])('does not retry HTTP %s', async (status) => { + fetchMock.mockResolvedValueOnce(new Response(null, { status })) + await expect(typesafeProvider.executeRequest(REQUEST)).rejects.toThrow(`HTTP ${status}`) + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('retries connection failures within the shared provider retry budget', async () => { + vi.useFakeTimers() + fetchMock.mockRejectedValue(new TypeError('fetch failed')) + const result = expect(typesafeProvider.executeRequest(REQUEST)).rejects.toThrow('fetch failed') + await vi.runAllTimersAsync() + await result + expect(fetchMock).toHaveBeenCalledTimes(PROVIDER_MAX_RETRIES + 1) + }) + + it('stops retrying repeated server failures', async () => { + vi.useFakeTimers() + fetchMock.mockImplementation(async () => new Response(null, { status: 503 })) + const result = expect(typesafeProvider.executeRequest(REQUEST)).rejects.toThrow('HTTP 503') + await vi.runAllTimersAsync() + await result + expect(fetchMock).toHaveBeenCalledTimes(PROVIDER_MAX_RETRIES + 1) + }) + + it('gives a timed-out attempt a fresh deadline', async () => { + vi.useFakeTimers() + const deadline = new AbortController() + vi.spyOn(AbortSignal, 'timeout').mockReturnValueOnce(deadline.signal) + fetchMock.mockImplementationOnce(async () => { + deadline.abort(new DOMException('Timed out', 'TimeoutError')) + throw deadline.signal.reason + }) + const result = typesafeProvider.executeRequest(REQUEST) + await vi.runAllTimersAsync() + expect(await result).toMatchObject({ answers: RESULT.answers }) + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(fetchMock.mock.calls[1][1]?.signal?.aborted).toBe(false) + }) + + it('cancels immediately during Retry-After without sending another attempt', async () => { + vi.useFakeTimers() + const controller = new AbortController() + fetchMock.mockResolvedValueOnce( + new Response(null, { status: 429, headers: { 'retry-after': '30' } }) + ) + const result = expect( + typesafeProvider.executeRequest({ ...REQUEST, abortSignal: controller.signal }) + ).rejects.toThrow('Cancelled') + await vi.advanceTimersByTimeAsync(1) + controller.abort(new Error('Cancelled')) + await result + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(vi.getTimerCount()).toBe(0) + }) + + it('does not retry a malformed successful response', async () => { + fetchMock.mockResolvedValueOnce(new Response('{')) + await expect(typesafeProvider.executeRequest(REQUEST)).rejects.toThrow() + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it.each([ + { label: 'ASCII values', character: 'x', bytes: 1, key: false }, + { label: 'UTF-8 values', character: '😀', bytes: 4, key: false }, + { label: 'control-character values', character: '\u0000', bytes: 6, key: false }, + { label: 'control-character keys', character: '\u0000', bytes: 6, key: true }, + { label: 'lone-surrogate values', character: '\ud800', bytes: 6, key: false }, + { label: 'lone-surrogate keys', character: '\ud800', bytes: 6, key: true }, + ])('rejects oversized $label before serialization or HTTP', async ({ character, bytes, key }) => { + const text = character.repeat(Math.ceil(MAX_EVALUATION_REQUEST_BYTES / bytes)) + const body = { + model: REQUEST.model, + state: key ? { [text]: null } : text, + questions: QUESTIONS, + } + const serialize = vi.spyOn(JSON, 'stringify') + await expect(requestJevEvaluation(body, 'test-key')).rejects.toThrow( + 'size or JSON complexity limit' + ) + expect(serialize).not.toHaveBeenCalled() + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('honors cancellation before network access', async () => { + await expect( + typesafeProvider.executeRequest({ ...REQUEST, abortSignal: AbortSignal.abort() }) + ).rejects.toThrow() + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('forwards cancellation to the request and bounds response allocation', async () => { + const controller = new AbortController() + fetchMock.mockImplementation(async (_url, init) => { + controller.abort() + expect(init?.signal?.aborted).toBe(true) + throw controller.signal.reason + }) + await expect( + typesafeProvider.executeRequest({ ...REQUEST, abortSignal: controller.signal }) + ).rejects.toThrow() + fetchMock.mockResolvedValue( + new Response('{}', { headers: { 'content-length': String(11 * 1024 * 1024) } }) + ) + await expect(typesafeProvider.executeRequest(REQUEST)).rejects.toThrow('exceeds maximum size') + }) +}) + +describe('Jev native schema', () => { + it.each([ + {}, + { bad: { type: 'chat', instructions: 'Hello' } }, + { bad: { type: 'choice', instructions: 'Pick', criteria: {} } }, + { + bad: { + type: 'choice', + instructions: 'Pick', + criteria: Object.fromEntries(Array.from({ length: 256 }, (_, i) => [String(i), null])), + }, + }, + { bad: { type: 'score', instructions: 'Rate', criteria: ['One'] } }, + { bad: { type: 'score', instructions: 'Rate', criteria: Array(11).fill('Level') } }, + { bad: { type: 'noul', instructions: 'Test', criteria: { yes: 'Wrong key' } } }, + ])('rejects invalid question shape %j', (questions) => { + expect(() => buildJevBody({ model: REQUEST.model, state: 'Test' }, questions)).toThrow( + 'Invalid Jev questions' + ) + }) + + it.each([null, true, 42])('rejects invalid state %j', (state) => { + expect(() => buildJevBody({ model: REQUEST.model, state }, QUESTIONS)).toThrow('Jev state') + }) + + it('accepts structured instructions and all question types together', () => { + expect( + buildJevBody( + { model: REQUEST.model, state: { content: 'Test' } }, + { + ...QUESTIONS, + urgent: { + type: 'noul', + instructions: ['Is this urgent?'], + criteria: { true: { deadline: 'today' }, false: 'No deadline' }, + }, + } + ).questions.urgent.instructions + ).toEqual(['Is this urgent?']) + }) + + it('rejects malformed question JSON', () => { + expect(() => buildJevBody({ model: REQUEST.model, state: 'Test' }, '{')).toThrow('valid JSON') + }) + + it.each([ + {}, + { ...RESULT.answers, unexpected: { type: 'noul', noul: 0.1 } }, + { ...RESULT.answers, department: { type: 'noul', noul: 0.1 } }, + ])('rejects mismatched answer IDs or types', (answers) => { + expect(() => parseJevResponse({ ...RESULT, answers }, QUESTIONS)).toThrow('do not match') + }) + + it.each([ + { ...RESULT, usage: { input_tokens: -1, output_tokens: 0 } }, + { ...RESULT, answers: { urgent: { type: 'noul', noul: 1.1 } } }, + { model: 'jev-1.13.0', choices: [] }, + ])('rejects invalid provider responses', (value) => { + expect(() => parseJevResponse(value, QUESTIONS)).toThrow('invalid Jev evaluation response') + }) + + it.each(['unknown', 'toString'])('rejects an unrequested Choice option %s', (choice) => { + const answers = { + ...RESULT.answers, + department: { type: 'choice', choice, probabilities: { [choice]: 1 }, confidence: 1 }, + } + expect(() => parseJevResponse({ ...RESULT, answers }, QUESTIONS)).toThrow( + 'outside the requested options' + ) + }) +}) diff --git a/apps/sim/providers/utils.test.ts b/apps/sim/providers/utils.test.ts index a3c148277e0..fe2c729c73e 100644 --- a/apps/sim/providers/utils.test.ts +++ b/apps/sim/providers/utils.test.ts @@ -952,6 +952,8 @@ describe('getHostedModels', () => { describe('shouldBillModelUsage', () => { it('should return true for exact matches of hosted models', () => { expect(shouldBillModelUsage('gpt-6-astra')).toBe(true) + expect(shouldBillModelUsage('gpt-6-sol')).toBe(true) + expect(shouldBillModelUsage('gpt-6-luna')).toBe(true) expect(shouldBillModelUsage('gpt-4o')).toBe(true) expect(shouldBillModelUsage('o1')).toBe(true) diff --git a/apps/sim/providers/utils.ts b/apps/sim/providers/utils.ts index c11f5831916..051655a11aa 100644 --- a/apps/sim/providers/utils.ts +++ b/apps/sim/providers/utils.ts @@ -160,6 +160,7 @@ export const providers: Record = { cerebras: buildProviderMetadata('cerebras'), groq: buildProviderMetadata('groq'), sakana: buildProviderMetadata('sakana'), + typesafe: buildProviderMetadata('typesafe'), nvidia: buildProviderMetadata('nvidia'), meta: buildProviderMetadata('meta'), zai: buildProviderMetadata('zai'), @@ -1175,10 +1176,17 @@ export function getApiKey(provider: string, model: string, userProvidedKey?: str const isZaiModel = provider === 'zai' const isXaiModel = provider === 'xai' const isKimiModel = provider === 'kimi' + const isTypeSafeModel = provider === 'typesafe' if ( isHosted && - (isOpenAIModel || isClaudeModel || isGeminiModel || isZaiModel || isXaiModel || isKimiModel) + (isOpenAIModel || + isClaudeModel || + isGeminiModel || + isZaiModel || + isXaiModel || + isKimiModel || + isTypeSafeModel) ) { const hostedModels = getHostedModels() const isModelHosted = hostedModels.some((m) => m.toLowerCase() === model.toLowerCase()) diff --git a/apps/sim/tools/types.ts b/apps/sim/tools/types.ts index ef4a63d92a5..29fee73c124 100644 --- a/apps/sim/tools/types.ts +++ b/apps/sim/tools/types.ts @@ -13,6 +13,7 @@ export type BYOKProviderId = | 'mistral' | 'zai' | 'kimi' + | 'typesafe' | 'xai' | 'fireworks' | 'together' diff --git a/packages/deployment-config/src/env-capabilities.ts b/packages/deployment-config/src/env-capabilities.ts index fc5ae477f58..9d8eb803a24 100644 --- a/packages/deployment-config/src/env-capabilities.ts +++ b/packages/deployment-config/src/env-capabilities.ts @@ -1545,6 +1545,7 @@ export const LLM_KEY_POOLS = { zai: { keys: ['ZAI_API_KEY_1', 'ZAI_API_KEY_2', 'ZAI_API_KEY_3'] }, xai: { keys: ['XAI_API_KEY_1', 'XAI_API_KEY_2', 'XAI_API_KEY_3'] }, kimi: { keys: ['KIMI_API_KEY_1', 'KIMI_API_KEY_2', 'KIMI_API_KEY_3'] }, + typesafe: { keys: ['TYPESAFE_API_KEY_1', 'TYPESAFE_API_KEY_2', 'TYPESAFE_API_KEY_3'] }, fireworks: { keys: ['FIREWORKS_API_KEY_1', 'FIREWORKS_API_KEY_2', 'FIREWORKS_API_KEY_3'], fallbackKey: 'FIREWORKS_API_KEY', diff --git a/packages/testing/src/mocks/auth-oauth-utils.mock.ts b/packages/testing/src/mocks/auth-oauth-utils.mock.ts index a11162377c6..d989465695e 100644 --- a/packages/testing/src/mocks/auth-oauth-utils.mock.ts +++ b/packages/testing/src/mocks/auth-oauth-utils.mock.ts @@ -35,6 +35,7 @@ export const authOAuthUtilsMockFns = { mockGetOAuthToken: vi.fn(), mockRefreshAccessTokenIfNeeded: vi.fn(), mockRefreshTokenIfNeeded: vi.fn(), + mockGetCredentialTerminalRefreshError: vi.fn(async () => null), } /** @@ -54,4 +55,5 @@ export const authOAuthUtilsMock = { getOAuthToken: authOAuthUtilsMockFns.mockGetOAuthToken, refreshAccessTokenIfNeeded: authOAuthUtilsMockFns.mockRefreshAccessTokenIfNeeded, refreshTokenIfNeeded: authOAuthUtilsMockFns.mockRefreshTokenIfNeeded, + getCredentialTerminalRefreshError: authOAuthUtilsMockFns.mockGetCredentialTerminalRefreshError, } diff --git a/packages/workflow-types/src/blocks.ts b/packages/workflow-types/src/blocks.ts index 10e08a588b0..4aa6311d5e9 100644 --- a/packages/workflow-types/src/blocks.ts +++ b/packages/workflow-types/src/blocks.ts @@ -59,6 +59,8 @@ export type SubBlockType = | 'modal' export interface OutputCondition { + /** Keep the output selectable when the compared value is resolved at execution time. */ + allowReference?: boolean field: string value: string | number | boolean | Array not?: boolean