Skip to content

Commit 26b08d8

Browse files
fix(self-host): preserve capability compatibility
1 parent 391e580 commit 26b08d8

27 files changed

Lines changed: 642 additions & 283 deletions

apps/sim/.env.example

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ CRON_SECRET=your_cron_secret # Use `openssl rand -hex 32` to generate. Authentic
100100
# OCR_PROVIDER=local # One of: local, mistral, azure-mistral
101101

102102
# File Storage (Optional - defaults to local disk; use S3, Azure Blob, or Google Cloud Storage for production)
103-
# STORAGE_PROVIDER=local # One of: local, s3, azure, gcs. Required when cloud storage is configured
103+
# STORAGE_PROVIDER=local # Optional override: local, s3, azure, or gcs. Unset preserves Azure → S3 → GCS → local precedence
104104
# AWS_REGION=us-east-1 # Required with S3_BUCKET_NAME to enable S3. Use "auto" for Cloudflare R2
105105
# AWS_ACCESS_KEY_ID= # Omit to use the instance/IRSA credential chain
106106
# AWS_SECRET_ACCESS_KEY= # Omit to use the instance/IRSA credential chain

apps/sim/app/api/auth/instagram/authorize/route.ts

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import { type NextRequest, NextResponse } from 'next/server'
44
import { authorizeInstagramContract } from '@/lib/api/contracts/oauth-connections'
55
import { parseRequest } from '@/lib/api/server'
66
import { getSession } from '@/lib/auth'
7-
import { env } from '@/lib/core/config/env'
87
import { requireConfiguredOAuthClient } from '@/lib/core/config/env-capabilities.server'
98
import { getBaseUrl } from '@/lib/core/utils/urls'
109
import { isSameOrigin } from '@/lib/core/utils/validation'
@@ -29,13 +28,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
2928
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
3029
}
3130

32-
requireConfiguredOAuthClient('instagram')
33-
34-
const clientId = env.INSTAGRAM_CLIENT_ID
35-
if (!clientId) {
36-
logger.error('INSTAGRAM_CLIENT_ID not configured')
37-
return NextResponse.json({ error: 'Instagram client ID not configured' }, { status: 500 })
38-
}
31+
const {
32+
values: { INSTAGRAM_CLIENT_ID: clientId },
33+
} = requireConfiguredOAuthClient('instagram')
3934

4035
const parsed = await parseRequest(authorizeInstagramContract, request, {})
4136
if (!parsed.success) return parsed.response

apps/sim/app/api/auth/oauth2/callback/instagram/route.ts

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ import { type NextRequest, NextResponse } from 'next/server'
77
import { instagramCallbackContract } from '@/lib/api/contracts/oauth-connections'
88
import { parseRequest } from '@/lib/api/server'
99
import { getSession } from '@/lib/auth'
10-
import { env } from '@/lib/core/config/env'
10+
import { EnvCapabilityConfigurationError } from '@/lib/core/config/env-capabilities'
11+
import { requireConfiguredOAuthClient } from '@/lib/core/config/env-capabilities.server'
1112
import {
1213
DEFAULT_MAX_ERROR_BODY_BYTES,
1314
readResponseJsonWithLimit,
@@ -76,14 +77,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
7677
)
7778
}
7879

79-
const clientId = env.INSTAGRAM_CLIENT_ID
80-
const clientSecret = env.INSTAGRAM_CLIENT_SECRET
81-
if (!clientId || !clientSecret) {
82-
logger.error('Instagram credentials not configured')
83-
return clearOAuthCookies(
84-
NextResponse.redirect(`${baseUrl}/workspace?error=instagram_config_error`)
85-
)
86-
}
80+
const {
81+
values: { INSTAGRAM_CLIENT_ID: clientId, INSTAGRAM_CLIENT_SECRET: clientSecret },
82+
} = requireConfiguredOAuthClient('instagram')
8783

8884
if (!code) {
8985
logger.error('No authorization code received from Instagram')
@@ -318,8 +314,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
318314
return clearOAuthCookies(NextResponse.redirect(finalUrl.toString()))
319315
} catch (error) {
320316
logger.error('Error in Instagram OAuth callback', { error })
321-
return clearOAuthCookies(
322-
NextResponse.redirect(`${baseUrl}/workspace?error=instagram_callback_error`)
323-
)
317+
const errorCode =
318+
error instanceof EnvCapabilityConfigurationError && error.capabilityId === 'oauth'
319+
? 'instagram_config_error'
320+
: 'instagram_callback_error'
321+
return clearOAuthCookies(NextResponse.redirect(`${baseUrl}/workspace?error=${errorCode}`))
324322
}
325323
})

apps/sim/app/api/auth/oauth2/callback/shopify/route.ts

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ import {
77
shopifyShopDomainSchema,
88
} from '@/lib/api/contracts/oauth-connections'
99
import { getSession } from '@/lib/auth'
10-
import { env } from '@/lib/core/config/env'
10+
import { EnvCapabilityConfigurationError } from '@/lib/core/config/env-capabilities'
11+
import { requireConfiguredOAuthClient } from '@/lib/core/config/env-capabilities.server'
1112
import { getBaseUrl } from '@/lib/core/utils/urls'
1213
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1314

@@ -61,13 +62,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
6162
const storedState = request.cookies.get('shopify_oauth_state')?.value
6263
const storedShop = request.cookies.get('shopify_shop_domain')?.value
6364

64-
const clientId = env.SHOPIFY_CLIENT_ID
65-
const clientSecret = env.SHOPIFY_CLIENT_SECRET
66-
67-
if (!clientId || !clientSecret) {
68-
logger.error('Shopify credentials not configured')
69-
return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_config_error`)
70-
}
65+
const {
66+
values: { SHOPIFY_CLIENT_ID: clientId, SHOPIFY_CLIENT_SECRET: clientSecret },
67+
} = requireConfiguredOAuthClient('shopify')
7168

7269
if (!validateHmac(searchParams, clientSecret)) {
7370
logger.error('HMAC validation failed in Shopify OAuth callback')
@@ -164,6 +161,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
164161
return response
165162
} catch (error) {
166163
logger.error('Error in Shopify OAuth callback:', error)
167-
return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_callback_error`)
164+
const errorCode =
165+
error instanceof EnvCapabilityConfigurationError && error.capabilityId === 'oauth'
166+
? 'shopify_config_error'
167+
: 'shopify_callback_error'
168+
return NextResponse.redirect(`${baseUrl}/workspace?error=${errorCode}`)
168169
}
169170
})

apps/sim/app/api/auth/shopify/authorize/route.ts

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import {
66
shopifyShopDomainSchema,
77
} from '@/lib/api/contracts/oauth-connections'
88
import { getSession } from '@/lib/auth'
9-
import { env } from '@/lib/core/config/env'
109
import { requireConfiguredOAuthClient } from '@/lib/core/config/env-capabilities.server'
1110
import { getBaseUrl } from '@/lib/core/utils/urls'
1211
import { isSameOrigin } from '@/lib/core/utils/validation'
@@ -26,13 +25,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
2625
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
2726
}
2827

29-
requireConfiguredOAuthClient('shopify')
30-
31-
const clientId = env.SHOPIFY_CLIENT_ID
32-
if (!clientId) {
33-
logger.error('SHOPIFY_CLIENT_ID not configured')
34-
return NextResponse.json({ error: 'Shopify client ID not configured' }, { status: 500 })
35-
}
28+
const {
29+
values: { SHOPIFY_CLIENT_ID: clientId },
30+
} = requireConfiguredOAuthClient('shopify')
3631

3732
const query = shopifyAuthorizeQuerySchema.parse({
3833
shop: request.nextUrl.searchParams.get('shop') || undefined,

apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import {
1313
resolveCredentialDisplay,
1414
resolveOAuthServiceForIntegration,
1515
} from '@/lib/integrations'
16-
import { getServiceAccountMetadata } from '@/lib/integrations/service-account-metadata'
1716
import { credentialProviderMatchesService } from '@/lib/oauth'
1817
import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal'
1918
import { RESOURCE_TILE_BASE } from '@/app/workspace/[workspaceId]/components/resource-tile'
@@ -98,14 +97,8 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration
9897
serviceName: oauthService?.serviceName,
9998
serviceIcon: oauthService?.serviceIcon,
10099
})
101-
const serviceAccountMetadata = integration.oauthServiceId
102-
? getServiceAccountMetadata(integration.oauthServiceId)
103-
: undefined
104100
const serviceAccountDeploymentAvailable =
105-
availability?.state === 'ready' ||
106-
availability?.state === 'limited' ||
107-
(availability?.state === 'unavailable' &&
108-
serviceAccountMetadata?.deploymentRequirement === 'preview-gated')
101+
availability?.state === 'ready' || availability?.state === 'limited'
109102
const hasServiceAccount =
110103
serviceAccountDeploymentAvailable &&
111104
Boolean(serviceAccountTarget) &&

apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx

Lines changed: 1 addition & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,13 @@ import {
1313
} from '@sim/emcn'
1414
import { useParams } from 'next/navigation'
1515
import { useQueryStates } from 'nuqs'
16-
import { getServiceAccountGatingBlockType } from '@/lib/credentials/service-account-provider-ids'
1716
import {
1817
blockTypeToIconMap,
1918
formatIntegrationType,
2019
INTEGRATIONS,
2120
type Integration,
2221
resolveCredentialDisplay,
2322
} from '@/lib/integrations'
24-
import { getServiceAccountMetadata } from '@/lib/integrations/service-account-metadata'
2523
import { IntegrationSection } from '@/app/workspace/[workspaceId]/integrations/components/integration-section'
2624
import { IntegrationTabsHeader } from '@/app/workspace/[workspaceId]/integrations/components/integration-tabs-header'
2725
import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase'
@@ -36,8 +34,6 @@ import {
3634
} from '@/app/workspace/[workspaceId]/integrations/search-params'
3735
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
3836
import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
39-
import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay'
40-
import { overlayVisibility } from '@/blocks/visibility/context'
4137
import { useWorkspaceCredentials, type WorkspaceCredential } from '@/hooks/queries/credentials'
4238
import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter'
4339
import { usePermissionConfig } from '@/hooks/use-permission-config'
@@ -66,31 +62,6 @@ const ALL_CATEGORY_SECTIONS: readonly { label: string; integrations: Integration
6662
})).sort((a, b) => a.label.localeCompare(b.label))
6763
})()
6864

69-
/** Resolves preview-gated service-account paths revealed for the current viewer. */
70-
function useRevealedServiceAccountIntegrationTypes(): ReadonlySet<string> {
71-
const overlayVersion = useCustomBlockOverlayVersion()
72-
return useMemo(() => {
73-
const visibility = overlayVisibility()
74-
return new Set(
75-
INTEGRATIONS.flatMap((integration) => {
76-
if (!integration.oauthServiceId) return []
77-
const metadata = getServiceAccountMetadata(integration.oauthServiceId)
78-
if (metadata?.deploymentRequirement !== 'preview-gated') return []
79-
const gatingBlockType = getServiceAccountGatingBlockType(metadata.providerId)
80-
if (!gatingBlockType) {
81-
throw new Error(
82-
`Preview-gated service account ${metadata.providerId} has no gating block type`
83-
)
84-
}
85-
return visibility?.revealed.has(gatingBlockType) &&
86-
!visibility.disabled.has(gatingBlockType)
87-
? [integration.type.toLowerCase()]
88-
: []
89-
})
90-
)
91-
}, [overlayVersion])
92-
}
93-
9465
interface IntegrationItemProps {
9566
blockType: string
9667
slug: string
@@ -167,7 +138,6 @@ export function Integrations() {
167138
const params = useParams()
168139
const workspaceId = (params?.workspaceId as string) || ''
169140
const { integrationAvailability } = usePermissionConfig()
170-
const revealedServiceAccountIntegrationTypes = useRevealedServiceAccountIntegrationTypes()
171141

172142
const [{ category: selectedCategory, search: urlSearchTerm }, setIntegrationFilters] =
173143
useQueryStates(integrationsParsers, integrationsUrlKeys)
@@ -379,9 +349,6 @@ export function Integrations() {
379349
const availability = integrationAvailability.get(integration.type.toLowerCase())
380350
const deploymentUnavailable =
381351
availability?.state === 'unavailable' || availability?.state === 'misconfigured'
382-
const previewServiceAccountAvailable = revealedServiceAccountIntegrationTypes.has(
383-
integration.type.toLowerCase()
384-
)
385352
return (
386353
<IntegrationItem
387354
key={integration.type}
@@ -391,11 +358,7 @@ export function Integrations() {
391358
name={integration.name}
392359
description={integration.description}
393360
icon={Icon}
394-
unavailable={
395-
integration.authType === 'oauth' &&
396-
!previewServiceAccountAvailable &&
397-
deploymentUnavailable
398-
}
361+
unavailable={integration.authType === 'oauth' && deploymentUnavailable}
399362
/>
400363
)
401364
})}

apps/sim/hooks/use-permission-config.ts

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,17 @@ import {
1010
type IntegrationAvailabilityResponse,
1111
} from '@/lib/api/contracts/common'
1212
import { getEnv, isTruthy } from '@/lib/core/config/env'
13-
import { isDeploymentGatedIntegrationType } from '@/lib/integrations/availability'
13+
import {
14+
isDeploymentGatedIntegrationType,
15+
resolveIntegrationAvailabilityStateForVisibility,
16+
} from '@/lib/integrations/availability'
1417
import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access'
1518
import {
1619
DEFAULT_PERMISSION_GROUP_CONFIG,
1720
type PermissionGroupConfig,
1821
} from '@/lib/permission-groups/types'
22+
import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay'
23+
import { overlayVisibility } from '@/blocks/visibility/context'
1924
import { useUserPermissionConfig } from '@/ee/access-control/hooks/permission-groups'
2025

2126
export interface PermissionConfigResult {
@@ -59,6 +64,7 @@ function intersectAllowlists(a: string[] | null, b: string[] | null): string[] |
5964
export function usePermissionConfig(): PermissionConfigResult {
6065
const params = useParams()
6166
const workspaceId = typeof params?.workspaceId === 'string' ? params.workspaceId : undefined
67+
const blockOverlayVersion = useCustomBlockOverlayVersion()
6268

6369
const { data: permissionData, isLoading: isPermissionLoading } =
6470
useUserPermissionConfig(workspaceId)
@@ -81,16 +87,18 @@ export function usePermissionConfig(): PermissionConfigResult {
8187
return intersectAllowlists(config.allowedIntegrations, envAllowlist)
8288
}, [config.allowedIntegrations, envAllowlistData])
8389

84-
const integrationAvailability = useMemo(
85-
() =>
86-
new Map(
87-
(envAllowlistData?.integrationAvailability ?? []).map((availability) => [
88-
availability.type.toLowerCase(),
89-
availability,
90-
])
91-
),
92-
[envAllowlistData?.integrationAvailability]
93-
)
90+
const integrationAvailability = useMemo(() => {
91+
const visibility = overlayVisibility()
92+
return new Map(
93+
(envAllowlistData?.integrationAvailability ?? []).map((availability) => [
94+
availability.type.toLowerCase(),
95+
{
96+
...availability,
97+
state: resolveIntegrationAvailabilityStateForVisibility(availability, visibility),
98+
},
99+
])
100+
)
101+
}, [envAllowlistData?.integrationAvailability, blockOverlayVersion])
94102

95103
const isBlockAllowed = useMemo(() => {
96104
return (blockType: string) => {
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { resetEnvMock, setEnv } from '@sim/testing'
5+
import { afterAll, beforeEach, describe, expect, expectTypeOf, it } from 'vitest'
6+
import {
7+
inspectConfiguredOAuthClient,
8+
requireConfiguredOAuthClient,
9+
} from '@/lib/core/config/env-capabilities.server'
10+
11+
describe('server environment capabilities', () => {
12+
beforeEach(() => {
13+
setEnv({
14+
SHOPIFY_CLIENT_ID: undefined,
15+
SHOPIFY_CLIENT_SECRET: undefined,
16+
SLACK_CLIENT_ID: undefined,
17+
SLACK_CLIENT_SECRET: undefined,
18+
})
19+
})
20+
21+
afterAll(resetEnvMock)
22+
23+
it('inspects partial OAuth configuration without throwing', () => {
24+
setEnv({ SLACK_CLIENT_ID: 'slack-client' })
25+
26+
expect(inspectConfiguredOAuthClient('slack')).toEqual({
27+
state: 'partial',
28+
missingFields: ['SLACK_CLIENT_SECRET'],
29+
setupCommand: 'bun run setup integration slack',
30+
})
31+
})
32+
33+
it('fails fast when an OAuth client is absent', () => {
34+
expect(() => requireConfiguredOAuthClient('shopify')).toThrow(
35+
'OAuth client shopify is not configured. Run bun run setup integration shopify.'
36+
)
37+
})
38+
39+
it('fails fast when an OAuth client is partially configured', () => {
40+
setEnv({ SLACK_CLIENT_ID: 'slack-client' })
41+
42+
expect(() => requireConfiguredOAuthClient('slack')).toThrow(
43+
'OAuth client slack is partially configured — missing SLACK_CLIENT_SECRET. Run bun run setup integration slack.'
44+
)
45+
})
46+
47+
it('does not expose non-string OAuth values as configured credentials', () => {
48+
setEnv({
49+
SHOPIFY_CLIENT_ID: true,
50+
SHOPIFY_CLIENT_SECRET: 'shopify-secret',
51+
})
52+
53+
expect(inspectConfiguredOAuthClient('shopify')).toMatchObject({
54+
state: 'partial',
55+
missingFields: ['SHOPIFY_CLIENT_ID'],
56+
})
57+
expect(() => requireConfiguredOAuthClient('shopify')).toThrow(/SHOPIFY_CLIENT_ID/)
58+
})
59+
60+
it('returns the validated values with capability-specific field types', () => {
61+
setEnv({
62+
SHOPIFY_CLIENT_ID: 'shopify-client',
63+
SHOPIFY_CLIENT_SECRET: 'shopify-secret',
64+
})
65+
66+
const configured = requireConfiguredOAuthClient('shopify')
67+
68+
expect(configured.values).toEqual({
69+
SHOPIFY_CLIENT_ID: 'shopify-client',
70+
SHOPIFY_CLIENT_SECRET: 'shopify-secret',
71+
})
72+
expectTypeOf(configured.values.SHOPIFY_CLIENT_ID).toEqualTypeOf<string>()
73+
expectTypeOf(configured.values.SHOPIFY_CLIENT_SECRET).toEqualTypeOf<string>()
74+
})
75+
})

0 commit comments

Comments
 (0)