Skip to content

Commit e2ea49e

Browse files
BillLeoutsakosvl346Bill Leoutsakoscursoragenticecrasher321
authored
feat(instagram): add Instagram integration (#5568)
* feat(instagram): add Instagram Login OAuth, tools, and block * feat(instagram): add Gmail-style media uploads for publish ops Resolve UserFiles to Meta-fetchable presigned HTTPS URLs (600s TTL) via internal publish routes, and fix OAuth scope storage plus connect-draft wiring so Instagram Login publishing is testable end-to-end. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor(instagram): simplify messaging tools to direct requests, clean up types * fix(instagram): parallelize carousel child polling, enforce 2-10 items, extend poll window and insights periods, use canonical user_id in OAuth callback * fix(instagram): resolve user id from user_id only, accept numeric user_id Co-authored-by: Cursor <cursoragent@cursor.com> * fix(instagram): use form/query params for publish and comment endpoints, request message timestamps explicitly Co-authored-by: Cursor <cursoragent@cursor.com> * fix(instagram): normalize Graph ID outputs to strings so downstream .trim() calls are safe Co-authored-by: Cursor <cursoragent@cursor.com> * style(instagram): use brand gradient tile for the block icon Match the official Instagram look by filling the tile with the orange–pink–purple radial gradient so the white camera glyph sits on a full-bleed brand background. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(instagram): tighten publish defaults and cloud-storage upload UX Default Reel share-to-feed to Yes, drop unused media fields params, share publish transform helpers, and warn when cloud storage is missing for Meta-fetchable uploads. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(instagram): fail closed when cloud storage status is unknown Treat loading/error as blocked for requiresCloudStorage uploads, show the warning once the check finishes, and disable selecting local workspace files Meta cannot fetch. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(instagram): proactively refresh long-lived tokens before expiry Meta only allows refreshing still-valid Instagram tokens, so refresh within 14 days of expiry (after the 24h age gate) instead of waiting until after accessTokenExpiresAt. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(oauth): restore TikTok clientIdParamName JSDoc after merge The staging merge dropped the opening /** on ProviderAuthConfig.clientIdParamName, which broke TypeScript parse in CI. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(api): Zod-contract storage-status and ratchet validation baseline Wire /api/files/storage-status through a shared route contract so the strict API validation audit stays at zero non-Zod routes after the new Instagram cloud-storage check. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(instagram): match Gmail advanced media placeholders Drop public-URL paste hints from advanced fields and the cloud-storage banner so the UI mirrors Gmail attachments. Co-authored-by: Cursor <cursoragent@cursor.com> * code review + hide from toolbar * address comments * fix(instagram): drop hidden Instagram from OAuth catalog pin test Instagram is hideFromToolbar so it is excluded from integrations.json; the pinned slug map must not expect it until the block is visible again. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(instagram): validate client ID before creating connect draft Avoid orphan pending credential drafts when INSTAGRAM_CLIENT_ID is missing, matching the Shopify authorize ordering. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Bill Leoutsakos <billleoutsakos@Bills-MacBook-Pro.local> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
1 parent 85c3a7b commit e2ea49e

107 files changed

Lines changed: 10192 additions & 788 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/docs/components/icons.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1467,6 +1467,21 @@ export function LinkedInIcon(props: SVGProps<SVGSVGElement>) {
14671467
)
14681468
}
14691469

1470+
/** Instagram camera glyph rendered with the block's brand gradient background. */
1471+
export function InstagramIcon(props: SVGProps<SVGSVGElement>) {
1472+
return (
1473+
<svg
1474+
{...props}
1475+
viewBox='0 0 24 24'
1476+
fill='currentColor'
1477+
xmlns='http://www.w3.org/2000/svg'
1478+
aria-hidden='true'
1479+
>
1480+
<path d='M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 100 12.324 6.162 6.162 0 000-12.324zM12 16a4 4 0 110-8 4 4 0 010 8zm6.406-11.845a1.44 1.44 0 100 2.881 1.44 1.44 0 000-2.881z' />
1481+
</svg>
1482+
)
1483+
}
1484+
14701485
export function CrunchbaseIcon(props: SVGProps<SVGSVGElement>) {
14711486
return (
14721487
<svg

apps/sim/.env.example

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,16 @@ API_ENCRYPTION_KEY=your_api_encryption_key # Use `openssl rand -hex 32` to gener
9696
# S3_ENDPOINT= # Custom endpoint for S3-compatible storage (Cloudflare R2, MinIO, Backblaze B2). Leave unset for AWS S3
9797
# S3_FORCE_PATH_STYLE=true # Required for MinIO/Ceph RGW. Leave unset for AWS S3 and R2
9898

99+
# Instagram OAuth (Optional - Instagram App ID/Secret from Meta App Dashboard > Instagram > API setup with Instagram login)
100+
# INSTAGRAM_CLIENT_ID=
101+
# INSTAGRAM_CLIENT_SECRET=
102+
# Instagram publish file uploads require S3 or Azure Blob above (Meta must fetch a public HTTPS URL).
103+
# Gmail attachments do not need this.
104+
105+
# TikTok OAuth (Optional - Client Key/Secret from the TikTok for Developers app)
106+
# TIKTOK_CLIENT_ID=
107+
# TIKTOK_CLIENT_SECRET=
108+
99109
# Azure Blob Storage takes precedence over S3 if both are configured
100110
# AZURE_ACCOUNT_NAME= # Azure storage account name
101111
# AZURE_ACCOUNT_KEY= # Azure storage account key
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { createLogger } from '@sim/logger'
2+
import { generateShortId } from '@sim/utils/id'
3+
import { type NextRequest, NextResponse } from 'next/server'
4+
import { authorizeInstagramContract } from '@/lib/api/contracts/oauth-connections'
5+
import { parseRequest } from '@/lib/api/server'
6+
import { getSession } from '@/lib/auth'
7+
import { env } from '@/lib/core/config/env'
8+
import { getBaseUrl } from '@/lib/core/utils/urls'
9+
import { isSameOrigin } from '@/lib/core/utils/validation'
10+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
11+
import { createConnectDraft } from '@/lib/credentials/connect-draft'
12+
import { getCanonicalScopesForProvider } from '@/lib/oauth/utils'
13+
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
14+
15+
const logger = createLogger('InstagramAuthorize')
16+
17+
export const dynamic = 'force-dynamic'
18+
19+
const INSTAGRAM_STATE_COOKIE = 'instagram_oauth_state'
20+
const INSTAGRAM_RETURN_URL_COOKIE = 'instagram_return_url'
21+
const INSTAGRAM_STATE_COOKIE_PATH = '/api/auth'
22+
const INSTAGRAM_STATE_COOKIE_MAX_AGE_SECONDS = 60 * 10
23+
24+
export const GET = withRouteHandler(async (request: NextRequest) => {
25+
try {
26+
const session = await getSession()
27+
if (!session?.user?.id) {
28+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
29+
}
30+
31+
const clientId = env.INSTAGRAM_CLIENT_ID
32+
if (!clientId) {
33+
logger.error('INSTAGRAM_CLIENT_ID not configured')
34+
return NextResponse.json({ error: 'Instagram client ID not configured' }, { status: 500 })
35+
}
36+
37+
const parsed = await parseRequest(authorizeInstagramContract, request, {})
38+
if (!parsed.success) return parsed.response
39+
const { returnUrl, workspaceId } = parsed.data.query
40+
41+
if (workspaceId) {
42+
const access = await checkWorkspaceAccess(workspaceId, session.user.id)
43+
if (!access.canWrite) {
44+
return NextResponse.json({ error: 'Workspace write access denied' }, { status: 403 })
45+
}
46+
await createConnectDraft({
47+
userId: session.user.id,
48+
workspaceId,
49+
providerId: 'instagram',
50+
})
51+
}
52+
53+
const baseUrl = getBaseUrl()
54+
const state = generateShortId(32)
55+
const redirectUri = `${baseUrl}/api/auth/oauth2/callback/instagram`
56+
const scope = getCanonicalScopesForProvider('instagram').join(',')
57+
58+
const authUrl = new URL('https://www.instagram.com/oauth/authorize')
59+
authUrl.searchParams.set('client_id', clientId)
60+
authUrl.searchParams.set('redirect_uri', redirectUri)
61+
authUrl.searchParams.set('response_type', 'code')
62+
authUrl.searchParams.set('scope', scope)
63+
authUrl.searchParams.set('state', state)
64+
65+
const response = NextResponse.redirect(authUrl.toString())
66+
response.cookies.set(INSTAGRAM_STATE_COOKIE, state, {
67+
httpOnly: true,
68+
secure: process.env.NODE_ENV === 'production',
69+
sameSite: 'lax',
70+
maxAge: INSTAGRAM_STATE_COOKIE_MAX_AGE_SECONDS,
71+
path: INSTAGRAM_STATE_COOKIE_PATH,
72+
})
73+
74+
if (returnUrl && isSameOrigin(returnUrl)) {
75+
response.cookies.set(INSTAGRAM_RETURN_URL_COOKIE, returnUrl, {
76+
httpOnly: true,
77+
secure: process.env.NODE_ENV === 'production',
78+
sameSite: 'lax',
79+
maxAge: INSTAGRAM_STATE_COOKIE_MAX_AGE_SECONDS,
80+
path: INSTAGRAM_STATE_COOKIE_PATH,
81+
})
82+
}
83+
84+
return response
85+
} catch (error) {
86+
logger.error('Error starting Instagram OAuth', { error })
87+
return NextResponse.json({ error: 'Failed to start Instagram OAuth' }, { status: 500 })
88+
}
89+
})

apps/sim/app/api/auth/oauth/utils.ts

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
type TokenServiceAccountSecretBlob,
1414
} from '@/lib/credentials/token-service-accounts/server'
1515
import { refreshOAuthToken } from '@/lib/oauth'
16+
import { isInstagramProvider, shouldProactivelyRefreshInstagramToken } from '@/lib/oauth/instagram'
1617
import {
1718
getMicrosoftRefreshTokenExpiry,
1819
isMicrosoftProvider,
@@ -604,6 +605,7 @@ export async function getOAuthToken(userId: string, providerId: string): Promise
604605
accessTokenExpiresAt: account.accessTokenExpiresAt,
605606
idToken: account.idToken,
606607
scope: account.scope,
608+
updatedAt: account.updatedAt,
607609
})
608610
.from(account)
609611
.where(and(eq(account.userId, userId), eq(account.providerId, providerId)))
@@ -617,19 +619,33 @@ export async function getOAuthToken(userId: string, providerId: string): Promise
617619

618620
const credential = connections[0]
619621

620-
// Determine whether we should refresh: missing token OR expired token
622+
// Determine whether we should refresh: missing/expired token, or Instagram
623+
// long-lived token nearing expiry (Meta cannot refresh after expiry).
621624
const now = new Date()
622625
const tokenExpiry = credential.accessTokenExpiresAt
623-
const shouldAttemptRefresh =
626+
const accessTokenNeedsRefresh =
624627
!!credential.refreshToken && (!credential.accessToken || (tokenExpiry && tokenExpiry < now))
628+
const instagramNeedsProactiveRefresh =
629+
!!credential.refreshToken &&
630+
isInstagramProvider(providerId) &&
631+
shouldProactivelyRefreshInstagramToken({
632+
accessTokenExpiresAt: credential.accessTokenExpiresAt,
633+
updatedAt: credential.updatedAt,
634+
now,
635+
})
625636

626-
if (shouldAttemptRefresh) {
627-
return performCoalescedRefresh({
637+
if (accessTokenNeedsRefresh || instagramNeedsProactiveRefresh) {
638+
const fresh = await performCoalescedRefresh({
628639
accountId: credential.id,
629640
providerId,
630641
refreshToken: credential.refreshToken!,
631642
userId,
632643
})
644+
if (fresh) return fresh
645+
if (!accessTokenNeedsRefresh && credential.accessToken) {
646+
return credential.accessToken
647+
}
648+
return null
633649
}
634650

635651
if (!credential.accessToken) {
@@ -703,7 +719,18 @@ export async function refreshAccessTokenIfNeeded(
703719
refreshTokenExpiresAt &&
704720
refreshTokenExpiresAt <= proactiveRefreshThreshold
705721

706-
const shouldRefresh = accessTokenNeedsRefresh || refreshTokenNeedsProactiveRefresh
722+
// Instagram long-lived tokens can only be refreshed while still valid.
723+
const instagramNeedsProactiveRefresh =
724+
!!credential.refreshToken &&
725+
isInstagramProvider(credential.providerId) &&
726+
shouldProactivelyRefreshInstagramToken({
727+
accessTokenExpiresAt,
728+
updatedAt: credential.updatedAt,
729+
now,
730+
})
731+
732+
const shouldRefresh =
733+
accessTokenNeedsRefresh || refreshTokenNeedsProactiveRefresh || instagramNeedsProactiveRefresh
707734

708735
const accessToken = credential.accessToken
709736

@@ -720,8 +747,8 @@ export async function refreshAccessTokenIfNeeded(
720747
})
721748
if (fresh) return fresh
722749

723-
// If refresh was only triggered proactively (Microsoft refresh-token aging),
724-
// the still-valid access token is a fine fallback.
750+
// If refresh was only triggered proactively (Microsoft refresh-token aging /
751+
// Instagram long-lived nearing expiry), the still-valid access token is fine.
725752
if (!accessTokenNeedsRefresh && accessToken) {
726753
logger.info(`[${requestId}] Refresh unavailable; reusing still-valid access token`)
727754
return accessToken
@@ -769,7 +796,18 @@ export async function refreshTokenIfNeeded(
769796
refreshTokenExpiresAt &&
770797
refreshTokenExpiresAt <= proactiveRefreshThreshold
771798

772-
const shouldRefresh = accessTokenNeedsRefresh || refreshTokenNeedsProactiveRefresh
799+
// Instagram long-lived tokens can only be refreshed while still valid.
800+
const instagramNeedsProactiveRefresh =
801+
!!credential.refreshToken &&
802+
isInstagramProvider(credential.providerId) &&
803+
shouldProactivelyRefreshInstagramToken({
804+
accessTokenExpiresAt,
805+
updatedAt: credential.updatedAt,
806+
now,
807+
})
808+
809+
const shouldRefresh =
810+
accessTokenNeedsRefresh || refreshTokenNeedsProactiveRefresh || instagramNeedsProactiveRefresh
773811

774812
// If token appears valid and present, return it directly
775813
if (!shouldRefresh) {

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

Lines changed: 1 addition & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,81 +1,17 @@
1-
import { db } from '@sim/db'
2-
import { pendingCredentialDraft, user } from '@sim/db/schema'
31
import { createLogger } from '@sim/logger'
4-
import { generateId } from '@sim/utils/id'
5-
import { and, eq, lt } from 'drizzle-orm'
62
import { type NextRequest, NextResponse } from 'next/server'
73
import { authorizeOAuth2Contract } from '@/lib/api/contracts/oauth-connections'
84
import { parseRequest } from '@/lib/api/server'
95
import { auth, getSession } from '@/lib/auth/auth'
106
import { getBaseUrl } from '@/lib/core/utils/urls'
117
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
12-
import { getAllOAuthServices } from '@/lib/oauth/utils'
8+
import { createConnectDraft } from '@/lib/credentials/connect-draft'
139
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
1410

1511
const logger = createLogger('OAuth2Authorize')
1612

1713
export const dynamic = 'force-dynamic'
1814

19-
const DRAFT_TTL_MS = 15 * 60 * 1000
20-
21-
/**
22-
* Creates the pending credential draft at click time so its TTL starts when the
23-
* user actually initiates the connect. Better Auth's `account.create.after` hook
24-
* consumes this draft to materialize the real credential after the OAuth
25-
* callback; starting the clock here guarantees the draft outlives the (≤5 min)
26-
* OAuth round-trip rather than expiring mid-flow and silently producing no
27-
* credential.
28-
*/
29-
async function createConnectDraft(params: {
30-
userId: string
31-
workspaceId: string
32-
providerId: string
33-
}): Promise<void> {
34-
const { userId, workspaceId, providerId } = params
35-
36-
const service = getAllOAuthServices().find((s) => s.providerId === providerId)
37-
const serviceName = service?.name ?? providerId
38-
39-
let displayName = serviceName
40-
try {
41-
const [row] = await db.select({ name: user.name }).from(user).where(eq(user.id, userId))
42-
if (row?.name) {
43-
displayName = `${row.name}'s ${serviceName}`
44-
}
45-
} catch {
46-
// Fall back to service name only
47-
}
48-
49-
const now = new Date()
50-
const expiresAt = new Date(now.getTime() + DRAFT_TTL_MS)
51-
await db
52-
.delete(pendingCredentialDraft)
53-
.where(
54-
and(eq(pendingCredentialDraft.userId, userId), lt(pendingCredentialDraft.expiresAt, now))
55-
)
56-
await db
57-
.insert(pendingCredentialDraft)
58-
.values({
59-
id: generateId(),
60-
userId,
61-
workspaceId,
62-
providerId,
63-
displayName,
64-
expiresAt,
65-
createdAt: now,
66-
})
67-
.onConflictDoUpdate({
68-
target: [
69-
pendingCredentialDraft.userId,
70-
pendingCredentialDraft.providerId,
71-
pendingCredentialDraft.workspaceId,
72-
],
73-
set: { displayName, expiresAt, createdAt: now },
74-
})
75-
76-
logger.info('Created OAuth connect credential draft', { userId, workspaceId, providerId })
77-
}
78-
7915
/**
8016
* Browser-initiated entrypoint for linking a generic OAuth2 account.
8117
*/

0 commit comments

Comments
 (0)