chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { generateShortId } from '@sim/utils/id'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { authorizeTrelloContract } from '@/lib/api/contracts/oauth-connections'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { getCanonicalScopesForProvider } from '@/lib/oauth/utils'
|
||||
|
||||
const logger = createLogger('TrelloAuthorize')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const TRELLO_STATE_COOKIE = 'trello_oauth_state'
|
||||
const TRELLO_STATE_COOKIE_PATH = '/api/auth/trello'
|
||||
const TRELLO_STATE_COOKIE_MAX_AGE_SECONDS = 60 * 10
|
||||
|
||||
export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(authorizeTrelloContract, request, {})
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const apiKey = env.TRELLO_API_KEY
|
||||
|
||||
if (!apiKey) {
|
||||
logger.error('TRELLO_API_KEY not configured')
|
||||
return NextResponse.json({ error: 'Trello API key not configured' }, { status: 500 })
|
||||
}
|
||||
|
||||
const baseUrl = getBaseUrl()
|
||||
const state = generateShortId(32)
|
||||
const returnUrl = new URL('/api/auth/trello/callback', baseUrl)
|
||||
returnUrl.searchParams.set('state', state)
|
||||
const scope = getCanonicalScopesForProvider('trello').join(',')
|
||||
|
||||
const authUrl = new URL('https://trello.com/1/authorize')
|
||||
authUrl.searchParams.set('key', apiKey)
|
||||
authUrl.searchParams.set('name', 'Sim Studio')
|
||||
authUrl.searchParams.set('expiration', 'never')
|
||||
authUrl.searchParams.set('callback_method', 'fragment')
|
||||
authUrl.searchParams.set('response_type', 'token')
|
||||
authUrl.searchParams.set('scope', scope)
|
||||
authUrl.searchParams.set('return_url', returnUrl.toString())
|
||||
|
||||
const response = NextResponse.redirect(authUrl.toString())
|
||||
response.cookies.set(TRELLO_STATE_COOKIE, state, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: TRELLO_STATE_COOKIE_MAX_AGE_SECONDS,
|
||||
path: TRELLO_STATE_COOKIE_PATH,
|
||||
})
|
||||
return response
|
||||
} catch (error) {
|
||||
logger.error('Error initiating Trello authorization:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,179 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { trelloCallbackContract } from '@/lib/api/contracts/oauth-connections'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
|
||||
const logger = createLogger('TrelloCallback')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const TRELLO_STATE_COOKIE = 'trello_oauth_state'
|
||||
|
||||
function escapeForJsString(value: string): string {
|
||||
return value.replace(/[\\'"<>&\r\n\u2028\u2029]/g, (ch) => {
|
||||
return `\\u${ch.charCodeAt(0).toString(16).padStart(4, '0')}`
|
||||
})
|
||||
}
|
||||
|
||||
function renderErrorPage(baseUrl: string, redirectQuery: string) {
|
||||
return new NextResponse(
|
||||
`<!DOCTYPE html><html><head><meta charset="utf-8"><title>Trello connection failed</title></head><body><script>window.location.href=${JSON.stringify(`${baseUrl}/workspace?${redirectQuery}`)};</script><p>Trello connection failed. Redirecting...</p></body></html>`,
|
||||
{
|
||||
status: 400,
|
||||
headers: {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
'Cache-Control': 'no-store, no-cache, must-revalidate',
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
const parsed = await parseRequest(trelloCallbackContract, request, {})
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const baseUrl = getBaseUrl()
|
||||
const queryState = parsed.data.query.state
|
||||
const cookieState = request.cookies.get(TRELLO_STATE_COOKIE)?.value
|
||||
|
||||
if (!queryState || !cookieState || queryState !== cookieState) {
|
||||
logger.warn('Trello callback rejected: state mismatch or missing state', {
|
||||
hasQueryState: Boolean(queryState),
|
||||
hasCookieState: Boolean(cookieState),
|
||||
})
|
||||
const response = renderErrorPage(baseUrl, 'error=trello_state_mismatch')
|
||||
response.cookies.delete({ name: TRELLO_STATE_COOKIE, path: '/api/auth/trello' })
|
||||
return response
|
||||
}
|
||||
|
||||
const safeState = escapeForJsString(queryState)
|
||||
|
||||
return new NextResponse(
|
||||
`<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Connecting to Trello...</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
background: linear-gradient(135deg, #0052CC 0%, #0079BF 100%);
|
||||
}
|
||||
.container {
|
||||
background: white;
|
||||
padding: 2rem;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,0.1);
|
||||
text-align: center;
|
||||
max-width: 400px;
|
||||
}
|
||||
.spinner {
|
||||
border: 4px solid #f3f3f3;
|
||||
border-top: 4px solid #0052CC;
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 1rem;
|
||||
}
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
.error {
|
||||
color: #ef4444;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
h2 {
|
||||
color: #111827;
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
p {
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="spinner"></div>
|
||||
<h2>Connecting to Trello</h2>
|
||||
<p id="status">Processing authorization...</p>
|
||||
<p id="error" class="error" style="display:none;"></p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const statusEl = document.getElementById('status');
|
||||
const errorEl = document.getElementById('error');
|
||||
|
||||
try {
|
||||
const fragment = window.location.hash.substring(1);
|
||||
const params = new URLSearchParams(fragment);
|
||||
const token = params.get('token');
|
||||
const authError = params.get('error');
|
||||
|
||||
if (authError) {
|
||||
throw new Error(authError);
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
throw new Error('No token received from Trello');
|
||||
}
|
||||
|
||||
statusEl.textContent = 'Saving your connection...';
|
||||
|
||||
fetch('${baseUrl}/api/auth/trello/store', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ token: token, state: '${safeState}' })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
statusEl.textContent = 'Success! Redirecting...';
|
||||
setTimeout(function() {
|
||||
window.location.href = '${baseUrl}/workspace?trello_connected=true';
|
||||
}, 500);
|
||||
} else {
|
||||
throw new Error(data.error || 'Failed to save connection');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
errorEl.textContent = error.message || 'Failed to save connection';
|
||||
errorEl.style.display = 'block';
|
||||
statusEl.textContent = 'Connection failed';
|
||||
setTimeout(function() {
|
||||
window.location.href = '${baseUrl}/workspace?error=trello_failed';
|
||||
}, 3000);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
errorEl.textContent = error.message || 'Authorization failed';
|
||||
errorEl.style.display = 'block';
|
||||
statusEl.textContent = 'Connection failed';
|
||||
setTimeout(function() {
|
||||
window.location.href = '${baseUrl}/workspace?error=trello_auth_failed';
|
||||
}, 3000);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
'Cache-Control': 'no-store, no-cache, must-revalidate',
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,153 @@
|
||||
import { db } from '@sim/db'
|
||||
import { account } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { storeTrelloTokenContract } from '@/lib/api/contracts/oauth-connections'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { processCredentialDraft } from '@/lib/credentials/draft-processor'
|
||||
import { getCanonicalScopesForProvider } from '@/lib/oauth/utils'
|
||||
import { safeAccountInsert } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
const logger = createLogger('TrelloStore')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const TRELLO_STATE_COOKIE = 'trello_oauth_state'
|
||||
const TRELLO_STATE_COOKIE_PATH = '/api/auth/trello'
|
||||
|
||||
function clearStateCookie(response: NextResponse) {
|
||||
response.cookies.delete({ name: TRELLO_STATE_COOKIE, path: TRELLO_STATE_COOKIE_PATH })
|
||||
return response
|
||||
}
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn('Unauthorized attempt to store Trello token')
|
||||
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(storeTrelloTokenContract, request, {})
|
||||
if (!parsed.success) return parsed.response
|
||||
const { token, state } = parsed.data.body
|
||||
|
||||
const cookieState = request.cookies.get(TRELLO_STATE_COOKIE)?.value
|
||||
if (!cookieState || cookieState !== state) {
|
||||
logger.warn('Trello store rejected: state mismatch', {
|
||||
hasCookieState: Boolean(cookieState),
|
||||
userId: session.user.id,
|
||||
})
|
||||
return clearStateCookie(
|
||||
NextResponse.json(
|
||||
{ success: false, error: 'Invalid or expired authorization state' },
|
||||
{ status: 400 }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const apiKey = env.TRELLO_API_KEY
|
||||
if (!apiKey) {
|
||||
logger.error('TRELLO_API_KEY not configured')
|
||||
return NextResponse.json({ success: false, error: 'Trello not configured' }, { status: 500 })
|
||||
}
|
||||
|
||||
const scope = getCanonicalScopesForProvider('trello').join(',')
|
||||
|
||||
const validationUrl = `https://api.trello.com/1/members/me?key=${apiKey}&token=${token}&fields=id,username,fullName`
|
||||
const userResponse = await fetch(validationUrl, {
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
|
||||
if (!userResponse.ok) {
|
||||
const errorText = await userResponse.text()
|
||||
logger.error('Invalid Trello token', {
|
||||
status: userResponse.status,
|
||||
error: errorText,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ success: false, error: `Invalid Trello token: ${errorText}` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const trelloUser = (await userResponse.json().catch(() => null)) as { id?: string } | null
|
||||
|
||||
if (typeof trelloUser?.id !== 'string' || trelloUser.id.trim().length === 0) {
|
||||
logger.error('Trello validation response did not include a valid member id', {
|
||||
response: trelloUser,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Invalid Trello member response' },
|
||||
{ status: 502 }
|
||||
)
|
||||
}
|
||||
|
||||
const existing = await db.query.account.findFirst({
|
||||
where: and(
|
||||
eq(account.userId, session.user.id),
|
||||
eq(account.providerId, 'trello'),
|
||||
eq(account.accountId, trelloUser.id)
|
||||
),
|
||||
})
|
||||
|
||||
const now = new Date()
|
||||
|
||||
if (existing) {
|
||||
await db
|
||||
.update(account)
|
||||
.set({
|
||||
accessToken: token,
|
||||
accountId: trelloUser.id,
|
||||
scope,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(account.id, existing.id))
|
||||
} else {
|
||||
await safeAccountInsert(
|
||||
{
|
||||
id: `trello_${session.user.id}_${Date.now()}`,
|
||||
userId: session.user.id,
|
||||
providerId: 'trello',
|
||||
accountId: trelloUser.id,
|
||||
accessToken: token,
|
||||
scope,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
{ provider: 'Trello', identifier: trelloUser.id }
|
||||
)
|
||||
}
|
||||
|
||||
const persisted =
|
||||
existing ??
|
||||
(await db.query.account.findFirst({
|
||||
where: and(
|
||||
eq(account.userId, session.user.id),
|
||||
eq(account.providerId, 'trello'),
|
||||
eq(account.accountId, trelloUser.id)
|
||||
),
|
||||
}))
|
||||
|
||||
if (persisted) {
|
||||
try {
|
||||
await processCredentialDraft({
|
||||
userId: session.user.id,
|
||||
providerId: 'trello',
|
||||
accountId: persisted.id,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to process credential draft for Trello', { error })
|
||||
}
|
||||
}
|
||||
|
||||
return clearStateCookie(NextResponse.json({ success: true }))
|
||||
} catch (error) {
|
||||
logger.error('Error storing Trello token:', error)
|
||||
return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user