v0.3.26: fix billing, bubble up workflow block errors, credentials security improvements

v0.3.26: fix billing, bubble up workflow block errors, credentials security improvements
This commit is contained in:
Vikhyath Mondreti
2025-08-14 14:17:25 -05:00
committed by GitHub
23 changed files with 365 additions and 282 deletions

View File

@@ -10,6 +10,8 @@ describe('OAuth Token API Routes', () => {
const mockGetUserId = vi.fn()
const mockGetCredential = vi.fn()
const mockRefreshTokenIfNeeded = vi.fn()
const mockAuthorizeCredentialUse = vi.fn()
const mockCheckHybridAuth = vi.fn()
const mockLogger = {
info: vi.fn(),
@@ -37,6 +39,14 @@ describe('OAuth Token API Routes', () => {
vi.doMock('@/lib/logs/console/logger', () => ({
createLogger: vi.fn().mockReturnValue(mockLogger),
}))
vi.doMock('@/lib/auth/credential-access', () => ({
authorizeCredentialUse: mockAuthorizeCredentialUse,
}))
vi.doMock('@/lib/auth/hybrid', () => ({
checkHybridAuth: mockCheckHybridAuth,
}))
})
afterEach(() => {
@@ -48,7 +58,12 @@ describe('OAuth Token API Routes', () => {
*/
describe('POST handler', () => {
it('should return access token successfully', async () => {
mockGetUserId.mockResolvedValueOnce('test-user-id')
mockAuthorizeCredentialUse.mockResolvedValueOnce({
ok: true,
authType: 'session',
requesterUserId: 'test-user-id',
credentialOwnerUserId: 'owner-user-id',
})
mockGetCredential.mockResolvedValueOnce({
id: 'credential-id',
accessToken: 'test-token',
@@ -78,14 +93,18 @@ describe('OAuth Token API Routes', () => {
expect(data).toHaveProperty('accessToken', 'fresh-token')
// Verify mocks were called correctly
// POST no longer calls getUserId; token resolution uses credential owner.
expect(mockGetUserId).not.toHaveBeenCalled()
expect(mockAuthorizeCredentialUse).toHaveBeenCalled()
expect(mockGetCredential).toHaveBeenCalled()
expect(mockRefreshTokenIfNeeded).toHaveBeenCalled()
})
it('should handle workflowId for server-side authentication', async () => {
mockGetUserId.mockResolvedValueOnce('workflow-owner-id')
mockAuthorizeCredentialUse.mockResolvedValueOnce({
ok: true,
authType: 'internal_jwt',
requesterUserId: 'workflow-owner-id',
credentialOwnerUserId: 'workflow-owner-id',
})
mockGetCredential.mockResolvedValueOnce({
id: 'credential-id',
accessToken: 'test-token',
@@ -111,8 +130,7 @@ describe('OAuth Token API Routes', () => {
expect(response.status).toBe(200)
expect(data).toHaveProperty('accessToken', 'fresh-token')
// POST no longer calls getUserId; still refreshes successfully
expect(mockGetUserId).not.toHaveBeenCalled()
expect(mockAuthorizeCredentialUse).toHaveBeenCalled()
expect(mockGetCredential).toHaveBeenCalled()
})
@@ -130,8 +148,10 @@ describe('OAuth Token API Routes', () => {
})
it('should handle authentication failure', async () => {
// Authentication failure no longer applies to POST path; treat as refresh failure via missing owner
mockGetUserId.mockResolvedValueOnce(undefined)
mockAuthorizeCredentialUse.mockResolvedValueOnce({
ok: false,
error: 'Authentication required',
})
const req = createMockRequest('POST', {
credentialId: 'credential-id',
@@ -142,12 +162,12 @@ describe('OAuth Token API Routes', () => {
const response = await POST(req)
const data = await response.json()
expect([401, 404]).toContain(response.status)
expect(response.status).toBe(403)
expect(data).toHaveProperty('error')
})
it('should handle workflow not found', async () => {
mockGetUserId.mockResolvedValueOnce(undefined)
mockAuthorizeCredentialUse.mockResolvedValueOnce({ ok: false, error: 'Workflow not found' })
const req = createMockRequest('POST', {
credentialId: 'credential-id',
@@ -159,13 +179,16 @@ describe('OAuth Token API Routes', () => {
const response = await POST(req)
const data = await response.json()
// With owner-based resolution, missing workflowId no longer matters.
// If credential not found via owner lookup, returns 404 accordingly
expect([401, 404]).toContain(response.status)
expect(response.status).toBe(403)
})
it('should handle credential not found', async () => {
mockGetUserId.mockResolvedValueOnce('test-user-id')
mockAuthorizeCredentialUse.mockResolvedValueOnce({
ok: true,
authType: 'session',
requesterUserId: 'test-user-id',
credentialOwnerUserId: 'owner-user-id',
})
mockGetCredential.mockResolvedValueOnce(undefined)
const req = createMockRequest('POST', {
@@ -177,12 +200,17 @@ describe('OAuth Token API Routes', () => {
const response = await POST(req)
const data = await response.json()
expect([401, 404]).toContain(response.status)
expect(response.status).toBe(401)
expect(data).toHaveProperty('error')
})
it('should handle token refresh failure', async () => {
mockGetUserId.mockResolvedValueOnce('test-user-id')
mockAuthorizeCredentialUse.mockResolvedValueOnce({
ok: true,
authType: 'session',
requesterUserId: 'test-user-id',
credentialOwnerUserId: 'owner-user-id',
})
mockGetCredential.mockResolvedValueOnce({
id: 'credential-id',
accessToken: 'test-token',
@@ -211,7 +239,11 @@ describe('OAuth Token API Routes', () => {
*/
describe('GET handler', () => {
it('should return access token successfully', async () => {
mockGetUserId.mockResolvedValueOnce('test-user-id')
mockCheckHybridAuth.mockResolvedValueOnce({
success: true,
authType: 'session',
userId: 'test-user-id',
})
mockGetCredential.mockResolvedValueOnce({
id: 'credential-id',
accessToken: 'test-token',
@@ -236,7 +268,7 @@ describe('OAuth Token API Routes', () => {
expect(response.status).toBe(200)
expect(data).toHaveProperty('accessToken', 'fresh-token')
expect(mockGetUserId).toHaveBeenCalledWith(mockRequestId)
expect(mockCheckHybridAuth).toHaveBeenCalled()
expect(mockGetCredential).toHaveBeenCalledWith(mockRequestId, 'credential-id', 'test-user-id')
expect(mockRefreshTokenIfNeeded).toHaveBeenCalled()
})
@@ -255,7 +287,10 @@ describe('OAuth Token API Routes', () => {
})
it('should handle authentication failure', async () => {
mockGetUserId.mockResolvedValueOnce(undefined)
mockCheckHybridAuth.mockResolvedValueOnce({
success: false,
error: 'Authentication required',
})
const req = new Request(
'http://localhost:3000/api/auth/oauth/token?credentialId=credential-id'
@@ -266,12 +301,16 @@ describe('OAuth Token API Routes', () => {
const response = await GET(req as any)
const data = await response.json()
expect([401, 404]).toContain(response.status)
expect(response.status).toBe(401)
expect(data).toHaveProperty('error')
})
it('should handle credential not found', async () => {
mockGetUserId.mockResolvedValueOnce('test-user-id')
mockCheckHybridAuth.mockResolvedValueOnce({
success: true,
authType: 'session',
userId: 'test-user-id',
})
mockGetCredential.mockResolvedValueOnce(undefined)
const req = new Request(
@@ -283,12 +322,16 @@ describe('OAuth Token API Routes', () => {
const response = await GET(req as any)
const data = await response.json()
expect([401, 404]).toContain(response.status)
expect(response.status).toBe(404)
expect(data).toHaveProperty('error')
})
it('should handle missing access token', async () => {
mockGetUserId.mockResolvedValueOnce('test-user-id')
mockCheckHybridAuth.mockResolvedValueOnce({
success: true,
authType: 'session',
userId: 'test-user-id',
})
mockGetCredential.mockResolvedValueOnce({
id: 'credential-id',
accessToken: null,
@@ -305,12 +348,16 @@ describe('OAuth Token API Routes', () => {
const response = await GET(req as any)
const data = await response.json()
expect([400, 401]).toContain(response.status)
expect(response.status).toBe(400)
expect(data).toHaveProperty('error')
})
it('should handle token refresh failure', async () => {
mockGetUserId.mockResolvedValueOnce('test-user-id')
mockCheckHybridAuth.mockResolvedValueOnce({
success: true,
authType: 'session',
userId: 'test-user-id',
})
mockGetCredential.mockResolvedValueOnce({
id: 'credential-id',
accessToken: 'test-token',
@@ -329,7 +376,7 @@ describe('OAuth Token API Routes', () => {
const response = await GET(req as any)
const data = await response.json()
expect([401, 404]).toContain(response.status)
expect(response.status).toBe(401)
expect(data).toHaveProperty('error')
})
})

View File

@@ -1,9 +1,8 @@
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { createLogger } from '@/lib/logs/console/logger'
import { getCredential, getUserId, refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { db } from '@/db'
import { account } from '@/db/schema'
import { getCredential, refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
export const dynamic = 'force-dynamic'
@@ -29,18 +28,13 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
}
// Resolve the credential owner directly by id. This lets API/UI/webhooks run under
// whichever user owns the persisted credential, not necessarily the session user
// or workflow owner.
const creds = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
if (!creds.length) {
logger.error(`[${requestId}] Credential not found: ${credentialId}`)
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
const authz = await authorizeCredentialUse(request, { credentialId, workflowId })
if (!authz.ok || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
const credentialOwnerUserId = creds[0].userId
// Fetch the credential verifying it belongs to the resolved owner
const credential = await getCredential(requestId, credentialId, credentialOwnerUserId)
// Fetch the credential as the owner to enforce ownership scoping
const credential = await getCredential(requestId, credentialId, authz.credentialOwnerUserId)
try {
// Refresh the token if needed
@@ -73,14 +67,13 @@ export async function GET(request: NextRequest) {
}
// For GET requests, we only support session-based authentication
const userId = await getUserId(requestId)
if (!userId) {
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
if (!auth.success || auth.authType !== 'session' || !auth.userId) {
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 })
}
// Get the credential from the database
const credential = await getCredential(requestId, credentialId, userId)
const credential = await getCredential(requestId, credentialId, auth.userId)
if (!credential) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })

View File

@@ -1,10 +1,7 @@
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { createLogger } from '@/lib/logs/console/logger'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { db } from '@/db'
import { account } from '@/db/schema'
export const dynamic = 'force-dynamic'
@@ -18,46 +15,28 @@ export async function GET(request: NextRequest) {
logger.info(`[${requestId}] Google Drive file request received`)
try {
// Get the session
const session = await getSession()
// Check if the user is authenticated
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthenticated request rejected`)
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 })
}
// Get the credential ID and file ID from the query params
const { searchParams } = new URL(request.url)
const credentialId = searchParams.get('credentialId')
const fileId = searchParams.get('fileId')
const workflowId = searchParams.get('workflowId')
const workflowId = searchParams.get('workflowId') || undefined
if (!credentialId || !fileId) {
logger.warn(`[${requestId}] Missing required parameters`)
return NextResponse.json({ error: 'Credential ID and File ID are required' }, { status: 400 })
}
// Get the credential from the database
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
if (!credentials.length) {
logger.warn(`[${requestId}] Credential not found`, { credentialId })
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
const credential = credentials[0]
// Credential ownership:
// - If session user owns the credential: allow
// - If not, allow read-only resolution when a workflowId is present (collaboration case)
if (credential.userId !== session.user.id && !workflowId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
const authz = await authorizeCredentialUse(request, { credentialId: credentialId, workflowId })
if (!authz.ok || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
// Refresh access token if needed using the utility function
const ownerUserId = credential.userId
const accessToken = await refreshAccessTokenIfNeeded(credentialId, ownerUserId, requestId)
const accessToken = await refreshAccessTokenIfNeeded(
credentialId,
authz.credentialOwnerUserId,
requestId
)
if (!accessToken) {
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })

View File

@@ -1,10 +1,7 @@
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { createLogger } from '@/lib/logs/console/logger'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { db } from '@/db'
import { account } from '@/db/schema'
export const dynamic = 'force-dynamic'
@@ -28,48 +25,26 @@ export async function GET(request: NextRequest) {
logger.info(`[${requestId}] Google Calendar calendars request received`)
try {
// Get the session
const session = await getSession()
// Check if the user is authenticated
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthenticated request rejected`)
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 })
}
// Get the credential ID from the query params
const { searchParams } = new URL(request.url)
const credentialId = searchParams.get('credentialId')
const workflowId = searchParams.get('workflowId')
const workflowId = searchParams.get('workflowId') || undefined
if (!credentialId) {
logger.warn(`[${requestId}] Missing credentialId parameter`)
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
}
// Get the credential from the database
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
if (!credentials.length) {
logger.warn(`[${requestId}] Credential not found`, { credentialId })
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
const credential = credentials[0]
// Allow collaborator read when workflowId present; otherwise require ownership
const ownerUserId = credential.userId
const requesterUserId = session.user.id
if (ownerUserId !== requesterUserId && !workflowId) {
logger.warn(`[${requestId}] Unauthorized credential access attempt`, {
credentialUserId: ownerUserId,
requestUserId: requesterUserId,
})
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
const authz = await authorizeCredentialUse(request, { credentialId, workflowId })
if (!authz.ok || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
// Refresh access token if needed using the utility function
const accessToken = await refreshAccessTokenIfNeeded(credentialId, ownerUserId, requestId)
const accessToken = await refreshAccessTokenIfNeeded(
credentialId,
authz.credentialOwnerUserId,
requestId
)
if (!accessToken) {
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })

View File

@@ -1,12 +1,9 @@
import type { Project } from '@linear/sdk'
import { LinearClient } from '@linear/sdk'
import { eq } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { createLogger } from '@/lib/logs/console/logger'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { db } from '@/db'
import { account } from '@/db/schema'
export const dynamic = 'force-dynamic'
@@ -14,7 +11,6 @@ const logger = createLogger('LinearProjectsAPI')
export async function POST(request: Request) {
try {
const session = await getSession()
const body = await request.json()
const { credential, teamId, workflowId } = body
@@ -23,38 +19,24 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Credential and teamId are required' }, { status: 400 })
}
const requesterUserId = session?.user?.id || ''
if (!requesterUserId) {
logger.error('No user ID found in session')
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
// Look up credential owner
const creds = await db.select().from(account).where(eq(account.id, credential)).limit(1)
if (!creds.length) {
logger.error('Credential not found for Linear API', { credential })
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
const credentialOwnerUserId = creds[0].userId
// If requester does not own the credential, allow only if workflowId present (collab context)
if (credentialOwnerUserId !== requesterUserId && !workflowId) {
logger.warn('Unauthorized Linear credential access attempt without workflow context', {
credentialOwnerUserId,
requesterUserId,
})
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
const requestId = crypto.randomUUID().slice(0, 8)
const authz = await authorizeCredentialUse(request as any, {
credentialId: credential,
workflowId,
})
if (!authz.ok || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
const accessToken = await refreshAccessTokenIfNeeded(
credential,
credentialOwnerUserId,
workflowId || 'linear'
authz.credentialOwnerUserId,
requestId
)
if (!accessToken) {
logger.error('Failed to get access token', {
credentialId: credential,
userId: credentialOwnerUserId,
userId: authz.credentialOwnerUserId,
})
return NextResponse.json(
{

View File

@@ -1,12 +1,9 @@
import type { Team } from '@linear/sdk'
import { LinearClient } from '@linear/sdk'
import { eq } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { createLogger } from '@/lib/logs/console/logger'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { db } from '@/db'
import { account } from '@/db/schema'
export const dynamic = 'force-dynamic'
@@ -14,7 +11,7 @@ const logger = createLogger('LinearTeamsAPI')
export async function POST(request: Request) {
try {
const session = await getSession()
const requestId = crypto.randomUUID().slice(0, 8)
const body = await request.json()
const { credential, workflowId } = body
@@ -23,38 +20,23 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
}
const requesterUserId = session?.user?.id || ''
if (!requesterUserId) {
logger.error('No user ID found in session')
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
// Look up credential to determine owner
const creds = await db.select().from(account).where(eq(account.id, credential)).limit(1)
if (!creds.length) {
logger.error('Credential not found for Linear API', { credential })
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
const credentialOwnerUserId = creds[0].userId
// If requester does not own the credential, allow only if workflowId present (collab context)
if (credentialOwnerUserId !== requesterUserId && !workflowId) {
logger.warn('Unauthorized Linear credential access attempt without workflow context', {
credentialOwnerUserId,
requesterUserId,
})
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
const authz = await authorizeCredentialUse(request as any, {
credentialId: credential,
workflowId,
})
if (!authz.ok || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
const accessToken = await refreshAccessTokenIfNeeded(
credential,
credentialOwnerUserId,
workflowId || 'linear'
authz.credentialOwnerUserId,
requestId
)
if (!accessToken) {
logger.error('Failed to get access token', {
credentialId: credential,
userId: credentialOwnerUserId,
userId: authz.credentialOwnerUserId,
})
return NextResponse.json(
{

View File

@@ -1,10 +1,7 @@
import { eq } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { createLogger } from '@/lib/logs/console/logger'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { db } from '@/db'
import { account } from '@/db/schema'
export const dynamic = 'force-dynamic'
@@ -12,7 +9,6 @@ const logger = createLogger('TeamsChannelsAPI')
export async function POST(request: Request) {
try {
const session = await getSession()
const body = await request.json()
const { credential, teamId, workflowId } = body
@@ -28,28 +24,24 @@ export async function POST(request: Request) {
}
try {
const userId = session?.user?.id || ''
if (!userId) {
logger.error('No user ID found in session')
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
const authz = await authorizeCredentialUse(request as any, {
credentialId: credential,
workflowId,
})
if (!authz.ok || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
const creds = await db.select().from(account).where(eq(account.id, credential)).limit(1)
if (!creds.length) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
const ownerUserId = creds[0].userId
if (ownerUserId !== userId && !workflowId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
}
// Allow read-only resolution when a workflowId is present
const accessToken = await refreshAccessTokenIfNeeded(
credential,
ownerUserId,
authz.credentialOwnerUserId,
'TeamsChannelsAPI'
)
if (!accessToken) {
logger.error('Failed to get access token', { credentialId: credential, userId })
logger.error('Failed to get access token', {
credentialId: credential,
userId: authz.credentialOwnerUserId,
})
return NextResponse.json(
{
error: 'Could not retrieve access token',

View File

@@ -1,10 +1,7 @@
import { eq } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { createLogger } from '@/lib/logs/console/logger'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { db } from '@/db'
import { account } from '@/db/schema'
export const dynamic = 'force-dynamic'
@@ -118,7 +115,7 @@ const getChatDisplayName = async (
export async function POST(request: Request) {
try {
const session = await getSession()
const requestId = crypto.randomUUID().slice(0, 8)
const body = await request.json()
const { credential, workflowId } = body
@@ -129,21 +126,24 @@ export async function POST(request: Request) {
}
try {
const userId = session?.user?.id || ''
if (!userId) {
logger.error('No user ID found in session')
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
const authz = await authorizeCredentialUse(request as any, {
credentialId: credential,
workflowId,
})
if (!authz.ok || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
const creds = await db.select().from(account).where(eq(account.id, credential)).limit(1)
if (!creds.length) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
const ownerUserId = creds[0].userId
// Allow read-only resolution when a workflowId is present
const accessToken = await refreshAccessTokenIfNeeded(credential, ownerUserId, 'TeamsChatsAPI')
const accessToken = await refreshAccessTokenIfNeeded(
credential,
authz.credentialOwnerUserId,
'TeamsChatsAPI'
)
if (!accessToken) {
logger.error('Failed to get access token', { credentialId: credential, userId })
logger.error('Failed to get access token', {
credentialId: credential,
userId: authz.credentialOwnerUserId,
})
return NextResponse.json({ error: 'Could not retrieve access token' }, { status: 401 })
}

View File

@@ -1,10 +1,7 @@
import { eq } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { createLogger } from '@/lib/logs/console/logger'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { db } from '@/db'
import { account } from '@/db/schema'
export const dynamic = 'force-dynamic'
@@ -12,7 +9,6 @@ const logger = createLogger('TeamsTeamsAPI')
export async function POST(request: Request) {
try {
const session = await getSession()
const body = await request.json()
const { credential, workflowId } = body
@@ -23,27 +19,26 @@ export async function POST(request: Request) {
}
try {
const userId = session?.user?.id || ''
if (!userId) {
logger.error('No user ID found in session')
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
const requestId = crypto.randomUUID().slice(0, 8)
const authz = await authorizeCredentialUse(request as any, {
credentialId: credential,
workflowId,
})
if (!authz.ok || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
// Resolve credential owner
const creds = await db.select().from(account).where(eq(account.id, credential)).limit(1)
if (!creds.length) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
const ownerUserId = creds[0].userId
// If session doesn't own it and no workflow context, reject
if (ownerUserId !== userId && !workflowId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
}
// Allow read-only resolution when a workflowId is present, even if session user isn't the owner
const accessToken = await refreshAccessTokenIfNeeded(credential, ownerUserId, 'TeamsTeamsAPI')
const accessToken = await refreshAccessTokenIfNeeded(
credential,
authz.credentialOwnerUserId,
'TeamsTeamsAPI'
)
if (!accessToken) {
logger.error('Failed to get access token', { credentialId: credential, userId })
logger.error('Failed to get access token', {
credentialId: credential,
userId: authz.credentialOwnerUserId,
})
return NextResponse.json(
{
error: 'Could not retrieve access token',

View File

@@ -1,5 +1,5 @@
import { NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { createLogger } from '@/lib/logs/console/logger'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
@@ -17,7 +17,7 @@ interface SlackChannel {
export async function POST(request: Request) {
try {
const session = await getSession()
const requestId = crypto.randomUUID().slice(0, 8)
const body = await request.json()
const { credential, workflowId } = body
@@ -34,15 +34,23 @@ export async function POST(request: Request) {
isBotToken = true
logger.info('Using direct bot token for Slack API')
} else {
const userId = session?.user?.id || ''
if (!userId) {
logger.error('No user ID found in session')
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
const authz = await authorizeCredentialUse(request as any, {
credentialId: credential,
workflowId,
})
if (!authz.ok || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
const resolvedToken = await refreshAccessTokenIfNeeded(credential, userId, workflowId)
const resolvedToken = await refreshAccessTokenIfNeeded(
credential,
authz.credentialOwnerUserId,
requestId
)
if (!resolvedToken) {
logger.error('Failed to get access token', { credentialId: credential, userId })
logger.error('Failed to get access token', {
credentialId: credential,
userId: authz.credentialOwnerUserId,
})
return NextResponse.json(
{
error: 'Could not retrieve access token',

View File

@@ -8,7 +8,7 @@ import {
UserCircle,
Users,
} from 'lucide-react'
import { isBillingEnabled } from '@/lib/environment'
import { getEnv } from '@/lib/env'
import { cn } from '@/lib/utils'
import { useSubscriptionStore } from '@/stores/subscription/store'
@@ -98,6 +98,9 @@ export function SettingsNavigation({
const { getSubscriptionStatus } = useSubscriptionStore()
const subscription = getSubscriptionStatus()
// Get billing status
const isBillingEnabled = getEnv('NEXT_PUBLIC_BILLING_ENABLED') || false
const navigationItems = allNavigationItems.filter((item) => {
if (item.hideWhenBillingDisabled && !isBillingEnabled) {
return false

View File

@@ -3,7 +3,7 @@
import { useEffect, useRef, useState } from 'react'
import { X } from 'lucide-react'
import { Button, Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui'
import { isBillingEnabled } from '@/lib/environment'
import { getEnv } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
import {
@@ -44,6 +44,9 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
const { activeOrganization } = useOrganizationStore()
const hasLoadedInitialData = useRef(false)
// Get billing status
const isBillingEnabled = getEnv('NEXT_PUBLIC_BILLING_ENABLED') || false
useEffect(() => {
async function loadAllSettings() {
if (!open) return

View File

@@ -5,7 +5,7 @@ import { HelpCircle, LibraryBig, ScrollText, Search, Settings, Shapes } from 'lu
import { useParams, usePathname, useRouter } from 'next/navigation'
import { Button, ScrollArea, Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui'
import { useSession } from '@/lib/auth-client'
import { isBillingEnabled } from '@/lib/environment'
import { getEnv } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { generateWorkspaceName } from '@/lib/naming'
import { cn } from '@/lib/utils'
@@ -195,6 +195,9 @@ export function Sidebar() {
const userPermissions = useUserPermissionsContext()
const isLoading = workflowsLoading || sessionLoading
// Get billing status
const isBillingEnabled = getEnv('NEXT_PUBLIC_BILLING_ENABLED') || false
// Add state to prevent multiple simultaneous workflow creations
const [isCreatingWorkflow, setIsCreatingWorkflow] = useState(false)
// Add state to prevent multiple simultaneous workspace creations

View File

@@ -111,13 +111,9 @@ describe('WorkflowBlockHandler', () => {
'parent-workflow-id_sub_child-workflow-id_workflow-block-1'
)
const result = await handler.execute(mockBlock, inputs, mockContext)
expect(result).toEqual({
success: false,
error:
'Cyclic workflow dependency detected: parent-workflow-id_sub_child-workflow-id_workflow-block-1',
childWorkflowName: 'child-workflow-id',
})
await expect(handler.execute(mockBlock, inputs, mockContext)).rejects.toThrow(
'Error in child workflow "child-workflow-id": Cyclic workflow dependency detected: parent-workflow-id_sub_child-workflow-id_workflow-block-1'
)
})
it('should enforce maximum depth limit', async () => {
@@ -130,12 +126,9 @@ describe('WorkflowBlockHandler', () => {
'level1_sub_level2_sub_level3_sub_level4_sub_level5_sub_level6_sub_level7_sub_level8_sub_level9_sub_level10_sub_level11',
}
const result = await handler.execute(mockBlock, inputs, deepContext)
expect(result).toEqual({
success: false,
error: 'Maximum workflow nesting depth of 10 exceeded',
childWorkflowName: 'child-workflow-id',
})
await expect(handler.execute(mockBlock, inputs, deepContext)).rejects.toThrow(
'Error in child workflow "child-workflow-id": Maximum workflow nesting depth of 10 exceeded'
)
})
it('should handle child workflow not found', async () => {
@@ -147,12 +140,9 @@ describe('WorkflowBlockHandler', () => {
statusText: 'Not Found',
})
const result = await handler.execute(mockBlock, inputs, mockContext)
expect(result).toEqual({
success: false,
error: 'Child workflow non-existent-workflow not found',
childWorkflowName: 'non-existent-workflow',
})
await expect(handler.execute(mockBlock, inputs, mockContext)).rejects.toThrow(
'Error in child workflow "non-existent-workflow": Child workflow non-existent-workflow not found'
)
})
it('should handle fetch errors gracefully', async () => {
@@ -160,12 +150,9 @@ describe('WorkflowBlockHandler', () => {
mockFetch.mockRejectedValueOnce(new Error('Network error'))
const result = await handler.execute(mockBlock, inputs, mockContext)
expect(result).toEqual({
success: false,
error: 'Child workflow child-workflow-id not found',
childWorkflowName: 'child-workflow-id',
})
await expect(handler.execute(mockBlock, inputs, mockContext)).rejects.toThrow(
'Error in child workflow "child-workflow-id": Child workflow child-workflow-id not found'
)
})
})

View File

@@ -115,6 +115,12 @@ export class WorkflowBlockHandler implements BlockHandler {
duration
)
// If the child workflow failed, throw an error to trigger proper error handling in the parent
if ((mappedResult as any).success === false) {
const childError = (mappedResult as any).error || 'Unknown error'
throw new Error(`Error in child workflow "${childWorkflowName}": ${childError}`)
}
return mappedResult
} catch (error: any) {
logger.error(`Error executing child workflow ${workflowId}:`, error)
@@ -128,11 +134,15 @@ export class WorkflowBlockHandler implements BlockHandler {
const workflowMetadata = workflows[workflowId]
const childWorkflowName = workflowMetadata?.name || workflowId
return {
success: false,
error: error?.message || 'Child workflow execution failed',
childWorkflowName,
} as Record<string, any>
// Enhance error message with child workflow context
const originalError = error.message || 'Unknown error'
// Check if error message already has child workflow context to avoid duplication
if (originalError.startsWith('Error in child workflow')) {
throw error // Re-throw as-is to avoid duplication
}
throw new Error(`Error in child workflow "${childWorkflowName}": ${originalError}`)
}
}

View File

@@ -1448,7 +1448,7 @@ describe('Executor', () => {
}
)
it.concurrent('should surface child workflow failure in result without throwing', async () => {
it.concurrent('should propagate errors from child workflows to parent workflow', async () => {
const workflow = {
version: '1.0',
blocks: [
@@ -1488,12 +1488,17 @@ describe('Executor', () => {
const result = await executor.execute('test-workflow-id')
// Verify that child workflow failure is surfaced in the overall result
// Verify that child workflow errors propagate to parent
expect(result).toBeDefined()
if ('success' in result) {
// With reverted behavior, parent execution may still be considered successful overall,
// but the workflow block output should capture the failure. Only assert structure here.
expect(typeof result.success).toBe('boolean')
// The workflow should fail due to child workflow failure
expect(result.success).toBe(false)
expect(result.error).toBeDefined()
// Error message should indicate it came from a child workflow
if (result.error && typeof result.error === 'string') {
expect(result.error).toContain('Error in child workflow')
}
}
})
})

View File

@@ -0,0 +1,113 @@
import { eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { getUserEntityPermissions } from '@/lib/permissions/utils'
import { db } from '@/db'
import { account, workflow as workflowTable } from '@/db/schema'
export interface CredentialAccessResult {
ok: boolean
error?: string
authType?: 'session' | 'api_key' | 'internal_jwt'
requesterUserId?: string
credentialOwnerUserId?: string
workspaceId?: string
}
/**
* Centralizes auth + collaboration rules for credential use.
* - Uses checkHybridAuth to authenticate the caller
* - Fetches credential owner
* - Authorization rules:
* - session/api_key: allow if requester owns the credential; otherwise require workflowId and
* verify BOTH requester and owner have access to the workflow's workspace
* - internal_jwt: require workflowId (by default) and verify credential owner has access to the
* workflow's workspace (requester identity is the system/workflow)
*/
export async function authorizeCredentialUse(
request: NextRequest,
params: { credentialId: string; workflowId?: string; requireWorkflowIdForInternal?: boolean }
): Promise<CredentialAccessResult> {
const { credentialId, workflowId, requireWorkflowIdForInternal = true } = params
const auth = await checkHybridAuth(request, { requireWorkflowId: requireWorkflowIdForInternal })
if (!auth.success || !auth.userId) {
return { ok: false, error: auth.error || 'Authentication required' }
}
// Lookup credential owner
const [credRow] = await db
.select({ userId: account.userId })
.from(account)
.where(eq(account.id, credentialId))
.limit(1)
if (!credRow) {
return { ok: false, error: 'Credential not found' }
}
const credentialOwnerUserId = credRow.userId
// If requester owns the credential, allow immediately
if (auth.authType !== 'internal_jwt' && auth.userId === credentialOwnerUserId) {
return {
ok: true,
authType: auth.authType,
requesterUserId: auth.userId,
credentialOwnerUserId,
}
}
// For collaboration paths, workflowId is required to scope to a workspace
if (!workflowId) {
return { ok: false, error: 'workflowId is required' }
}
const [wf] = await db
.select({ workspaceId: workflowTable.workspaceId })
.from(workflowTable)
.where(eq(workflowTable.id, workflowId))
.limit(1)
if (!wf || !wf.workspaceId) {
return { ok: false, error: 'Workflow not found' }
}
if (auth.authType === 'internal_jwt') {
// Internal calls: verify credential owner belongs to the workflow's workspace
const ownerPerm = await getUserEntityPermissions(
credentialOwnerUserId,
'workspace',
wf.workspaceId
)
if (ownerPerm === null) {
return { ok: false, error: 'Unauthorized' }
}
return {
ok: true,
authType: auth.authType,
requesterUserId: auth.userId,
credentialOwnerUserId,
workspaceId: wf.workspaceId,
}
}
// Session/API key: verify BOTH requester and owner belong to the workflow's workspace
const requesterPerm = await getUserEntityPermissions(auth.userId, 'workspace', wf.workspaceId)
const ownerPerm = await getUserEntityPermissions(
credentialOwnerUserId,
'workspace',
wf.workspaceId
)
if (requesterPerm === null || ownerPerm === null) {
return { ok: false, error: 'Unauthorized' }
}
return {
ok: true,
authType: auth.authType,
requesterUserId: auth.userId,
credentialOwnerUserId,
workspaceId: wf.workspaceId,
}
}

View File

@@ -179,6 +179,9 @@ export const env = createEnv({
// Asset Storage
NEXT_PUBLIC_BLOB_BASE_URL: z.string().url().optional(), // Base URL for Vercel Blob storage (CDN assets)
// Billing
NEXT_PUBLIC_BILLING_ENABLED: z.boolean().optional(), // Enable billing enforcement and usage tracking (client-side)
// Google Services - For client-side Google integrations
NEXT_PUBLIC_GOOGLE_CLIENT_ID: z.string().optional(), // Google OAuth client ID for browser auth
@@ -216,6 +219,7 @@ export const env = createEnv({
NEXT_PUBLIC_VERCEL_URL: process.env.NEXT_PUBLIC_VERCEL_URL,
NEXT_PUBLIC_SENTRY_DSN: process.env.NEXT_PUBLIC_SENTRY_DSN,
NEXT_PUBLIC_BLOB_BASE_URL: process.env.NEXT_PUBLIC_BLOB_BASE_URL,
NEXT_PUBLIC_BILLING_ENABLED: process.env.NEXT_PUBLIC_BILLING_ENABLED,
NEXT_PUBLIC_GOOGLE_CLIENT_ID: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID,
NEXT_PUBLIC_RB2B_KEY: process.env.NEXT_PUBLIC_RB2B_KEY,
NEXT_PUBLIC_GOOGLE_API_KEY: process.env.NEXT_PUBLIC_GOOGLE_API_KEY,

View File

@@ -183,8 +183,8 @@ export const appendTool: ToolConfig<GoogleSheetsToolParams, GoogleSheetsAppendRe
transformResponse: async (response: Response) => {
const data = await response.json()
// Extract spreadsheet ID from the URL
const urlParts = response.url.split('/spreadsheets/')
// Extract spreadsheet ID from the URL (guard if url is missing)
const urlParts = typeof response.url === 'string' ? response.url.split('/spreadsheets/') : []
const spreadsheetId = urlParts[1]?.split('/')[0] || ''
// Create a simple metadata object with just the ID and URL

View File

@@ -66,8 +66,8 @@ export const readTool: ToolConfig<GoogleSheetsToolParams, GoogleSheetsReadRespon
transformResponse: async (response: Response) => {
const data = await response.json()
// Extract spreadsheet ID from the URL
const urlParts = response.url.split('/spreadsheets/')
// Extract spreadsheet ID from the URL (guard if url is missing)
const urlParts = typeof response.url === 'string' ? response.url.split('/spreadsheets/') : []
const spreadsheetId = urlParts[1]?.split('/')[0] || ''
// Create a simple metadata object with just the ID and URL

View File

@@ -137,8 +137,8 @@ export const updateTool: ToolConfig<GoogleSheetsToolParams, GoogleSheetsUpdateRe
transformResponse: async (response: Response) => {
const data = await response.json()
// Extract spreadsheet ID from the URL
const urlParts = response.url.split('/spreadsheets/')
// Extract spreadsheet ID from the URL (guard if url is missing)
const urlParts = typeof response.url === 'string' ? response.url.split('/spreadsheets/') : []
const spreadsheetId = urlParts[1]?.split('/')[0] || ''
// Create a simple metadata object with just the ID and URL

View File

@@ -134,8 +134,8 @@ export const writeTool: ToolConfig<GoogleSheetsToolParams, GoogleSheetsWriteResp
transformResponse: async (response: Response) => {
const data = await response.json()
// Extract spreadsheet ID from the URL
const urlParts = response.url.split('/spreadsheets/')
// Extract spreadsheet ID from the URL (guard if url is missing)
const urlParts = typeof response.url === 'string' ? response.url.split('/spreadsheets/') : []
const spreadsheetId = urlParts[1]?.split('/')[0] || ''
// Create a simple metadata object with just the ID and URL

View File

@@ -467,6 +467,8 @@ async function handleInternalRequest(
status: response.status,
statusText: response.statusText,
headers: response.headers,
// Provide the resolved URL so tool transforms can safely read response.url
url: fullUrl,
json: async () => responseData,
text: async () =>
typeof responseData === 'string' ? responseData : JSON.stringify(responseData),