feat(confluence): added more confluence endpoints (#3139)

* feat(confluence): added more confluence endpoints

* update license

* updated

* updated docs
This commit is contained in:
Waleed
2026-02-04 19:46:28 -08:00
committed by GitHub
parent 2147309365
commit 552dc56fc3
129 changed files with 5074 additions and 556 deletions

View File

@@ -1,7 +1,7 @@
'use client'
import { useBrandConfig } from '@/lib/branding/branding'
import { inter } from '@/app/_styles/fonts/inter/inter'
import { useBrandConfig } from '@/ee/whitelabeling'
export interface SupportFooterProps {
/** Position style - 'fixed' for pages without AuthLayout, 'absolute' for pages with AuthLayout */

View File

@@ -7,10 +7,10 @@ import Image from 'next/image'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { GithubIcon } from '@/components/icons'
import { useBrandConfig } from '@/lib/branding/branding'
import { isHosted } from '@/lib/core/config/feature-flags'
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
import { getFormattedGitHubStars } from '@/app/(landing)/actions/github'
import { useBrandConfig } from '@/ee/whitelabeling'
import { useBrandedButtonClass } from '@/hooks/use-branded-button-class'
const logger = createLogger('nav')

View File

@@ -14,7 +14,6 @@ import {
parseWorkflowSSEChunk,
} from '@/lib/a2a/utils'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { getBrandConfig } from '@/lib/branding/branding'
import { acquireLock, getRedisClient, releaseLock } from '@/lib/core/config/redis'
import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server'
import { SSE_HEADERS } from '@/lib/core/utils/sse'
@@ -35,6 +34,7 @@ import {
type PushNotificationSetParams,
type TaskIdParams,
} from '@/app/api/a2a/serve/[agentId]/utils'
import { getBrandConfig } from '@/ee/whitelabeling'
const logger = createLogger('A2AServeAPI')

View File

@@ -21,7 +21,8 @@ export async function GET(request: NextRequest) {
const accessToken = searchParams.get('accessToken')
const pageId = searchParams.get('pageId')
const providedCloudId = searchParams.get('cloudId')
const limit = searchParams.get('limit') || '25'
const limit = searchParams.get('limit') || '50'
const cursor = searchParams.get('cursor')
if (!domain) {
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
@@ -47,7 +48,12 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}/attachments?limit=${limit}`
const queryParams = new URLSearchParams()
queryParams.append('limit', String(Math.min(Number(limit), 250)))
if (cursor) {
queryParams.append('cursor', cursor)
}
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}/attachments?${queryParams.toString()}`
const response = await fetch(url, {
method: 'GET',
@@ -77,9 +83,20 @@ export async function GET(request: NextRequest) {
fileSize: attachment.fileSize || 0,
mediaType: attachment.mediaType || '',
downloadUrl: attachment.downloadLink || attachment._links?.download || '',
status: attachment.status ?? null,
webuiUrl: attachment._links?.webui ?? null,
pageId: attachment.pageId ?? null,
blogPostId: attachment.blogPostId ?? null,
comment: attachment.comment ?? null,
version: attachment.version ?? null,
}))
return NextResponse.json({ attachments })
return NextResponse.json({
attachments,
nextCursor: data._links?.next
? new URL(data._links.next, 'https://placeholder').searchParams.get('cursor')
: null,
})
} catch (error) {
logger.error('Error listing Confluence attachments:', error)
return NextResponse.json(

View File

@@ -0,0 +1,285 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
import { getConfluenceCloudId } from '@/tools/confluence/utils'
const logger = createLogger('ConfluenceBlogPostsAPI')
export const dynamic = 'force-dynamic'
const getBlogPostSchema = z
.object({
domain: z.string().min(1, 'Domain is required'),
accessToken: z.string().min(1, 'Access token is required'),
cloudId: z.string().optional(),
blogPostId: z.string().min(1, 'Blog post ID is required'),
bodyFormat: z.string().optional(),
})
.refine(
(data) => {
const validation = validateAlphanumericId(data.blogPostId, 'blogPostId', 255)
return validation.isValid
},
(data) => {
const validation = validateAlphanumericId(data.blogPostId, 'blogPostId', 255)
return { message: validation.error || 'Invalid blog post ID', path: ['blogPostId'] }
}
)
const createBlogPostSchema = z.object({
domain: z.string().min(1, 'Domain is required'),
accessToken: z.string().min(1, 'Access token is required'),
cloudId: z.string().optional(),
spaceId: z.string().min(1, 'Space ID is required'),
title: z.string().min(1, 'Title is required'),
content: z.string().min(1, 'Content is required'),
status: z.enum(['current', 'draft']).optional(),
})
/**
* List all blog posts or get a specific blog post
*/
export async function GET(request: NextRequest) {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const domain = searchParams.get('domain')
const accessToken = searchParams.get('accessToken')
const providedCloudId = searchParams.get('cloudId')
const limit = searchParams.get('limit') || '25'
const status = searchParams.get('status')
const sortOrder = searchParams.get('sort')
const cursor = searchParams.get('cursor')
if (!domain) {
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
}
if (!accessToken) {
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
const queryParams = new URLSearchParams()
queryParams.append('limit', String(Math.min(Number(limit), 250)))
if (status) {
queryParams.append('status', status)
}
if (sortOrder) {
queryParams.append('sort', sortOrder)
}
if (cursor) {
queryParams.append('cursor', cursor)
}
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/blogposts?${queryParams.toString()}`
const response = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
const errorData = await response.json().catch(() => null)
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: JSON.stringify(errorData, null, 2),
})
const errorMessage = errorData?.message || `Failed to list blog posts (${response.status})`
return NextResponse.json({ error: errorMessage }, { status: response.status })
}
const data = await response.json()
const blogPosts = (data.results || []).map((post: any) => ({
id: post.id,
title: post.title,
status: post.status ?? null,
spaceId: post.spaceId ?? null,
authorId: post.authorId ?? null,
createdAt: post.createdAt ?? null,
version: post.version ?? null,
webUrl: post._links?.webui ?? null,
}))
return NextResponse.json({
blogPosts,
nextCursor: data._links?.next
? new URL(data._links.next, 'https://placeholder').searchParams.get('cursor')
: null,
})
} catch (error) {
logger.error('Error listing blog posts:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
}
/**
* Get a specific blog post by ID
*/
export async function POST(request: NextRequest) {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
// Check if this is a create or get request
if (body.title && body.content && body.spaceId) {
// Create blog post
const validation = createBlogPostSchema.safeParse(body)
if (!validation.success) {
const firstError = validation.error.errors[0]
return NextResponse.json({ error: firstError.message }, { status: 400 })
}
const {
domain,
accessToken,
cloudId: providedCloudId,
spaceId,
title,
content,
status,
} = validation.data
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/blogposts`
const createBody = {
spaceId,
status: status || 'current',
title,
body: {
representation: 'storage',
value: content,
},
}
const response = await fetch(url, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify(createBody),
})
if (!response.ok) {
const errorData = await response.json().catch(() => null)
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: JSON.stringify(errorData, null, 2),
})
const errorMessage = errorData?.message || `Failed to create blog post (${response.status})`
return NextResponse.json({ error: errorMessage }, { status: response.status })
}
const data = await response.json()
return NextResponse.json({
id: data.id,
title: data.title,
spaceId: data.spaceId,
webUrl: data._links?.webui ?? null,
})
}
// Get blog post by ID
const validation = getBlogPostSchema.safeParse(body)
if (!validation.success) {
const firstError = validation.error.errors[0]
return NextResponse.json({ error: firstError.message }, { status: 400 })
}
const {
domain,
accessToken,
cloudId: providedCloudId,
blogPostId,
bodyFormat,
} = validation.data
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
const queryParams = new URLSearchParams()
if (bodyFormat) {
queryParams.append('body-format', bodyFormat)
}
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/blogposts/${blogPostId}${queryParams.toString() ? `?${queryParams.toString()}` : ''}`
const response = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
const errorData = await response.json().catch(() => null)
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: JSON.stringify(errorData, null, 2),
})
const errorMessage = errorData?.message || `Failed to get blog post (${response.status})`
return NextResponse.json({ error: errorMessage }, { status: response.status })
}
const data = await response.json()
return NextResponse.json({
id: data.id,
title: data.title,
status: data.status ?? null,
spaceId: data.spaceId ?? null,
authorId: data.authorId ?? null,
createdAt: data.createdAt ?? null,
version: data.version ?? null,
body: data.body ?? null,
webUrl: data._links?.webui ?? null,
})
} catch (error) {
logger.error('Error with blog post operation:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -105,6 +105,8 @@ export async function GET(request: NextRequest) {
const pageId = searchParams.get('pageId')
const providedCloudId = searchParams.get('cloudId')
const limit = searchParams.get('limit') || '25'
const bodyFormat = searchParams.get('bodyFormat') || 'storage'
const cursor = searchParams.get('cursor')
if (!domain) {
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
@@ -130,7 +132,13 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}/footer-comments?limit=${limit}`
const queryParams = new URLSearchParams()
queryParams.append('limit', String(Math.min(Number(limit), 250)))
queryParams.append('body-format', bodyFormat)
if (cursor) {
queryParams.append('cursor', cursor)
}
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}/footer-comments?${queryParams.toString()}`
const response = await fetch(url, {
method: 'GET',
@@ -154,14 +162,31 @@ export async function GET(request: NextRequest) {
const data = await response.json()
const comments = (data.results || []).map((comment: any) => ({
id: comment.id,
body: comment.body?.storage?.value || comment.body?.view?.value || '',
createdAt: comment.createdAt || '',
authorId: comment.authorId || '',
}))
const comments = (data.results || []).map((comment: any) => {
const bodyValue = comment.body?.storage?.value || comment.body?.view?.value || ''
return {
id: comment.id,
body: {
value: bodyValue,
representation: bodyFormat,
},
createdAt: comment.createdAt || '',
authorId: comment.authorId || '',
status: comment.status ?? null,
title: comment.title ?? null,
pageId: comment.pageId ?? null,
blogPostId: comment.blogPostId ?? null,
parentCommentId: comment.parentCommentId ?? null,
version: comment.version ?? null,
}
})
return NextResponse.json({ comments })
return NextResponse.json({
comments,
nextCursor: data._links?.next
? new URL(data._links.next, 'https://placeholder').searchParams.get('cursor')
: null,
})
} catch (error) {
logger.error('Error listing Confluence comments:', error)
return NextResponse.json(

View File

@@ -22,6 +22,7 @@ export async function POST(request: NextRequest) {
cloudId: providedCloudId,
pageId,
labelName,
prefix: labelPrefix,
} = await request.json()
if (!domain) {
@@ -52,12 +53,14 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}/labels`
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/rest/api/content/${pageId}/label`
const body = {
prefix: 'global',
name: labelName,
}
const body = [
{
prefix: labelPrefix || 'global',
name: labelName,
},
]
const response = await fetch(url, {
method: 'POST',
@@ -82,7 +85,14 @@ export async function POST(request: NextRequest) {
}
const data = await response.json()
return NextResponse.json({ ...data, pageId, labelName })
const addedLabel = data.results?.[0] || data[0] || data
return NextResponse.json({
id: addedLabel.id ?? '',
name: addedLabel.name ?? labelName,
prefix: addedLabel.prefix ?? labelPrefix ?? 'global',
pageId,
labelName,
})
} catch (error) {
logger.error('Error adding Confluence label:', error)
return NextResponse.json(
@@ -105,6 +115,8 @@ export async function GET(request: NextRequest) {
const accessToken = searchParams.get('accessToken')
const pageId = searchParams.get('pageId')
const providedCloudId = searchParams.get('cloudId')
const limit = searchParams.get('limit') || '25'
const cursor = searchParams.get('cursor')
if (!domain) {
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
@@ -130,7 +142,12 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}/labels`
const queryParams = new URLSearchParams()
queryParams.append('limit', String(Math.min(Number(limit), 250)))
if (cursor) {
queryParams.append('cursor', cursor)
}
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}/labels?${queryParams.toString()}`
const response = await fetch(url, {
method: 'GET',
@@ -160,7 +177,12 @@ export async function GET(request: NextRequest) {
prefix: label.prefix || 'global',
}))
return NextResponse.json({ labels })
return NextResponse.json({
labels,
nextCursor: data._links?.next
? new URL(data._links.next, 'https://placeholder').searchParams.get('cursor')
: null,
})
} catch (error) {
logger.error('Error listing Confluence labels:', error)
return NextResponse.json(

View File

@@ -0,0 +1,96 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
import { getConfluenceCloudId } from '@/tools/confluence/utils'
const logger = createLogger('ConfluencePageAncestorsAPI')
export const dynamic = 'force-dynamic'
/**
* Get ancestors (parent pages) of a specific Confluence page.
* Uses GET /wiki/api/v2/pages/{id}/ancestors
*/
export async function POST(request: NextRequest) {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const { domain, accessToken, pageId, cloudId: providedCloudId, limit = 25 } = body
if (!domain) {
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
}
if (!accessToken) {
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}
if (!pageId) {
return NextResponse.json({ error: 'Page ID is required' }, { status: 400 })
}
const pageIdValidation = validateAlphanumericId(pageId, 'pageId', 255)
if (!pageIdValidation.isValid) {
return NextResponse.json({ error: pageIdValidation.error }, { status: 400 })
}
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
const queryParams = new URLSearchParams()
queryParams.append('limit', String(Math.min(limit, 250)))
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}/ancestors?${queryParams.toString()}`
logger.info(`Fetching ancestors for page ${pageId}`)
const response = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
const errorData = await response.json().catch(() => null)
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: JSON.stringify(errorData, null, 2),
})
const errorMessage = errorData?.message || `Failed to get page ancestors (${response.status})`
return NextResponse.json({ error: errorMessage }, { status: response.status })
}
const data = await response.json()
const ancestors = (data.results || []).map((page: any) => ({
id: page.id,
title: page.title,
status: page.status ?? null,
spaceId: page.spaceId ?? null,
webUrl: page._links?.webui ?? null,
}))
return NextResponse.json({
ancestors,
pageId,
})
} catch (error) {
logger.error('Error getting page ancestors:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,104 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
import { getConfluenceCloudId } from '@/tools/confluence/utils'
const logger = createLogger('ConfluencePageChildrenAPI')
export const dynamic = 'force-dynamic'
/**
* Get child pages of a specific Confluence page.
* Uses GET /wiki/api/v2/pages/{id}/children
*/
export async function POST(request: NextRequest) {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const { domain, accessToken, pageId, cloudId: providedCloudId, limit = 50, cursor } = body
if (!domain) {
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
}
if (!accessToken) {
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}
if (!pageId) {
return NextResponse.json({ error: 'Page ID is required' }, { status: 400 })
}
const pageIdValidation = validateAlphanumericId(pageId, 'pageId', 255)
if (!pageIdValidation.isValid) {
return NextResponse.json({ error: pageIdValidation.error }, { status: 400 })
}
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
const queryParams = new URLSearchParams()
queryParams.append('limit', String(Math.min(limit, 250)))
if (cursor) {
queryParams.append('cursor', cursor)
}
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}/children?${queryParams.toString()}`
logger.info(`Fetching child pages for page ${pageId}`)
const response = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
const errorData = await response.json().catch(() => null)
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: JSON.stringify(errorData, null, 2),
})
const errorMessage = errorData?.message || `Failed to get child pages (${response.status})`
return NextResponse.json({ error: errorMessage }, { status: response.status })
}
const data = await response.json()
const children = (data.results || []).map((page: any) => ({
id: page.id,
title: page.title,
status: page.status ?? null,
spaceId: page.spaceId ?? null,
childPosition: page.childPosition ?? null,
webUrl: page._links?.webui ?? null,
}))
return NextResponse.json({
children,
parentId: pageId,
nextCursor: data._links?.next
? new URL(data._links.next, 'https://placeholder').searchParams.get('cursor')
: null,
})
} catch (error) {
logger.error('Error getting child pages:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,365 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
import { getConfluenceCloudId } from '@/tools/confluence/utils'
const logger = createLogger('ConfluencePagePropertiesAPI')
export const dynamic = 'force-dynamic'
const createPropertySchema = z.object({
domain: z.string().min(1, 'Domain is required'),
accessToken: z.string().min(1, 'Access token is required'),
cloudId: z.string().optional(),
pageId: z.string().min(1, 'Page ID is required'),
key: z.string().min(1, 'Property key is required'),
value: z.any(),
})
const updatePropertySchema = z.object({
domain: z.string().min(1, 'Domain is required'),
accessToken: z.string().min(1, 'Access token is required'),
cloudId: z.string().optional(),
pageId: z.string().min(1, 'Page ID is required'),
propertyId: z.string().min(1, 'Property ID is required'),
key: z.string().min(1, 'Property key is required'),
value: z.any(),
versionNumber: z.number().min(1, 'Version number is required'),
})
const deletePropertySchema = z.object({
domain: z.string().min(1, 'Domain is required'),
accessToken: z.string().min(1, 'Access token is required'),
cloudId: z.string().optional(),
pageId: z.string().min(1, 'Page ID is required'),
propertyId: z.string().min(1, 'Property ID is required'),
})
/**
* List all content properties on a page.
*/
export async function GET(request: NextRequest) {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const domain = searchParams.get('domain')
const accessToken = searchParams.get('accessToken')
const pageId = searchParams.get('pageId')
const providedCloudId = searchParams.get('cloudId')
const limit = searchParams.get('limit') || '50'
const cursor = searchParams.get('cursor')
if (!domain) {
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
}
if (!accessToken) {
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}
if (!pageId) {
return NextResponse.json({ error: 'Page ID is required' }, { status: 400 })
}
const pageIdValidation = validateAlphanumericId(pageId, 'pageId', 255)
if (!pageIdValidation.isValid) {
return NextResponse.json({ error: pageIdValidation.error }, { status: 400 })
}
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
const queryParams = new URLSearchParams()
queryParams.append('limit', String(Math.min(Number(limit), 250)))
if (cursor) {
queryParams.append('cursor', cursor)
}
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}/properties?${queryParams.toString()}`
const response = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
const errorData = await response.json().catch(() => null)
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: JSON.stringify(errorData, null, 2),
})
const errorMessage =
errorData?.message || `Failed to list page properties (${response.status})`
return NextResponse.json({ error: errorMessage }, { status: response.status })
}
const data = await response.json()
const properties = (data.results || []).map((prop: any) => ({
id: prop.id,
key: prop.key,
value: prop.value ?? null,
version: prop.version ?? null,
}))
return NextResponse.json({
properties,
pageId,
nextCursor: data._links?.next
? new URL(data._links.next, 'https://placeholder').searchParams.get('cursor')
: null,
})
} catch (error) {
logger.error('Error listing page properties:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
}
/**
* Create a new content property on a page.
*/
export async function POST(request: NextRequest) {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const validation = createPropertySchema.safeParse(body)
if (!validation.success) {
const firstError = validation.error.errors[0]
return NextResponse.json({ error: firstError.message }, { status: 400 })
}
const { domain, accessToken, cloudId: providedCloudId, pageId, key, value } = validation.data
const pageIdValidation = validateAlphanumericId(pageId, 'pageId', 255)
if (!pageIdValidation.isValid) {
return NextResponse.json({ error: pageIdValidation.error }, { status: 400 })
}
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}/properties`
const response = await fetch(url, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({ key, value }),
})
if (!response.ok) {
const errorData = await response.json().catch(() => null)
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: JSON.stringify(errorData, null, 2),
})
const errorMessage =
errorData?.message || `Failed to create page property (${response.status})`
return NextResponse.json({ error: errorMessage }, { status: response.status })
}
const data = await response.json()
return NextResponse.json({
id: data.id,
key: data.key,
value: data.value,
version: data.version,
pageId,
})
} catch (error) {
logger.error('Error creating page property:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
}
/**
* Update a content property on a page.
*/
export async function PUT(request: NextRequest) {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const validation = updatePropertySchema.safeParse(body)
if (!validation.success) {
const firstError = validation.error.errors[0]
return NextResponse.json({ error: firstError.message }, { status: 400 })
}
const {
domain,
accessToken,
cloudId: providedCloudId,
pageId,
propertyId,
key,
value,
versionNumber,
} = validation.data
const pageIdValidation = validateAlphanumericId(pageId, 'pageId', 255)
if (!pageIdValidation.isValid) {
return NextResponse.json({ error: pageIdValidation.error }, { status: 400 })
}
const propertyIdValidation = validateAlphanumericId(propertyId, 'propertyId', 255)
if (!propertyIdValidation.isValid) {
return NextResponse.json({ error: propertyIdValidation.error }, { status: 400 })
}
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}/properties/${propertyId}`
const response = await fetch(url, {
method: 'PUT',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({
key,
value,
version: { number: versionNumber },
}),
})
if (!response.ok) {
const errorData = await response.json().catch(() => null)
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: JSON.stringify(errorData, null, 2),
})
const errorMessage =
errorData?.message || `Failed to update page property (${response.status})`
return NextResponse.json({ error: errorMessage }, { status: response.status })
}
const data = await response.json()
return NextResponse.json({
id: data.id,
key: data.key,
value: data.value,
version: data.version,
pageId,
})
} catch (error) {
logger.error('Error updating page property:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
}
/**
* Delete a content property from a page.
*/
export async function DELETE(request: NextRequest) {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const validation = deletePropertySchema.safeParse(body)
if (!validation.success) {
const firstError = validation.error.errors[0]
return NextResponse.json({ error: firstError.message }, { status: 400 })
}
const { domain, accessToken, cloudId: providedCloudId, pageId, propertyId } = validation.data
const pageIdValidation = validateAlphanumericId(pageId, 'pageId', 255)
if (!pageIdValidation.isValid) {
return NextResponse.json({ error: pageIdValidation.error }, { status: 400 })
}
const propertyIdValidation = validateAlphanumericId(propertyId, 'propertyId', 255)
if (!propertyIdValidation.isValid) {
return NextResponse.json({ error: propertyIdValidation.error }, { status: 400 })
}
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}/properties/${propertyId}`
const response = await fetch(url, {
method: 'DELETE',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
const errorData = await response.json().catch(() => null)
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: JSON.stringify(errorData, null, 2),
})
const errorMessage =
errorData?.message || `Failed to delete page property (${response.status})`
return NextResponse.json({ error: errorMessage }, { status: response.status })
}
return NextResponse.json({ propertyId, pageId, deleted: true })
} catch (error) {
logger.error('Error deleting page property:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,151 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
import { getConfluenceCloudId } from '@/tools/confluence/utils'
const logger = createLogger('ConfluencePageVersionsAPI')
export const dynamic = 'force-dynamic'
/**
* List all versions of a page or get a specific version.
* Uses GET /wiki/api/v2/pages/{id}/versions
* and GET /wiki/api/v2/pages/{page-id}/versions/{version-number}
*/
export async function POST(request: NextRequest) {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const {
domain,
accessToken,
pageId,
versionNumber,
cloudId: providedCloudId,
limit = 50,
cursor,
} = body
if (!domain) {
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
}
if (!accessToken) {
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}
if (!pageId) {
return NextResponse.json({ error: 'Page ID is required' }, { status: 400 })
}
const pageIdValidation = validateAlphanumericId(pageId, 'pageId', 255)
if (!pageIdValidation.isValid) {
return NextResponse.json({ error: pageIdValidation.error }, { status: 400 })
}
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
// If versionNumber is provided, get specific version
if (versionNumber !== undefined && versionNumber !== null) {
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}/versions/${versionNumber}`
logger.info(`Fetching version ${versionNumber} for page ${pageId}`)
const response = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
const errorData = await response.json().catch(() => null)
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: JSON.stringify(errorData, null, 2),
})
const errorMessage = errorData?.message || `Failed to get page version (${response.status})`
return NextResponse.json({ error: errorMessage }, { status: response.status })
}
const data = await response.json()
return NextResponse.json({
version: {
number: data.number,
message: data.message ?? null,
minorEdit: data.minorEdit ?? false,
authorId: data.authorId ?? null,
createdAt: data.createdAt ?? null,
},
pageId,
})
}
// List all versions
const queryParams = new URLSearchParams()
queryParams.append('limit', String(Math.min(limit, 250)))
if (cursor) {
queryParams.append('cursor', cursor)
}
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}/versions?${queryParams.toString()}`
logger.info(`Fetching versions for page ${pageId}`)
const response = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
const errorData = await response.json().catch(() => null)
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: JSON.stringify(errorData, null, 2),
})
const errorMessage = errorData?.message || `Failed to list page versions (${response.status})`
return NextResponse.json({ error: errorMessage }, { status: response.status })
}
const data = await response.json()
const versions = (data.results || []).map((version: any) => ({
number: version.number,
message: version.message ?? null,
minorEdit: version.minorEdit ?? false,
authorId: version.authorId ?? null,
createdAt: version.createdAt ?? null,
}))
return NextResponse.json({
versions,
pageId,
nextCursor: data._links?.next
? new URL(data._links.next, 'https://placeholder').searchParams.get('cursor')
: null,
})
} catch (error) {
logger.error('Error with page versions:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -62,6 +62,7 @@ const deletePageSchema = z
accessToken: z.string().min(1, 'Access token is required'),
cloudId: z.string().optional(),
pageId: z.string().min(1, 'Page ID is required'),
purge: z.boolean().optional(),
})
.refine(
(data) => {
@@ -98,7 +99,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}?expand=body.storage,body.view,body.atlas_doc_format`
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}?body-format=storage`
const response = await fetch(url, {
method: 'GET',
@@ -130,16 +131,18 @@ export async function POST(request: NextRequest) {
id: data.id,
title: data.title,
body: {
view: {
value:
data.body?.storage?.value ||
data.body?.view?.value ||
data.body?.atlas_doc_format?.value ||
data.content || // try alternative fields
data.description ||
`Content for page ${data.title}`, // fallback content
storage: {
value: data.body?.storage?.value ?? null,
representation: 'storage',
},
},
status: data.status ?? null,
spaceId: data.spaceId ?? null,
parentId: data.parentId ?? null,
authorId: data.authorId ?? null,
createdAt: data.createdAt ?? null,
version: data.version ?? null,
_links: data._links ?? null,
})
} catch (error) {
logger.error('Error fetching Confluence page:', error)
@@ -274,7 +277,7 @@ export async function DELETE(request: NextRequest) {
return NextResponse.json({ error: firstError.message }, { status: 400 })
}
const { domain, accessToken, cloudId: providedCloudId, pageId } = validation.data
const { domain, accessToken, cloudId: providedCloudId, pageId, purge } = validation.data
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
@@ -283,7 +286,12 @@ export async function DELETE(request: NextRequest) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}`
const queryParams = new URLSearchParams()
if (purge) {
queryParams.append('purge', 'true')
}
const queryString = queryParams.toString()
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}${queryString ? `?${queryString}` : ''}`
const response = await fetch(url, {
method: 'DELETE',

View File

@@ -32,7 +32,6 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}
// Use provided cloudId or fetch it if not provided
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
@@ -40,7 +39,6 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
// Build the URL with query parameters
const baseUrl = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages`
const queryParams = new URLSearchParams()
@@ -57,7 +55,6 @@ export async function POST(request: NextRequest) {
logger.info(`Fetching Confluence pages from: ${url}`)
// Make the request to Confluence API with OAuth Bearer token
const response = await fetch(url, {
method: 'GET',
headers: {
@@ -79,7 +76,6 @@ export async function POST(request: NextRequest) {
} catch (e) {
logger.error('Could not parse error response as JSON:', e)
// Try to get the response text for more context
try {
const text = await response.text()
logger.error('Response text:', text)

View File

@@ -0,0 +1,120 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
import { getConfluenceCloudId } from '@/tools/confluence/utils'
const logger = createLogger('ConfluenceSearchInSpaceAPI')
export const dynamic = 'force-dynamic'
/**
* Search for content within a specific Confluence space using CQL.
*/
export async function POST(request: NextRequest) {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const {
domain,
accessToken,
spaceKey,
query,
cloudId: providedCloudId,
limit = 25,
contentType,
} = body
if (!domain) {
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
}
if (!accessToken) {
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}
if (!spaceKey) {
return NextResponse.json({ error: 'Space key is required' }, { status: 400 })
}
const spaceKeyValidation = validateAlphanumericId(spaceKey, 'spaceKey', 255)
if (!spaceKeyValidation.isValid) {
return NextResponse.json({ error: spaceKeyValidation.error }, { status: 400 })
}
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
const escapeCqlValue = (value: string) => value.replace(/"/g, '\\"')
let cql = `space = "${escapeCqlValue(spaceKey)}"`
if (query) {
cql += ` AND text ~ "${escapeCqlValue(query)}"`
}
if (contentType) {
cql += ` AND type = "${escapeCqlValue(contentType)}"`
}
const searchParams = new URLSearchParams({
cql,
limit: String(Math.min(limit, 250)),
})
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/rest/api/search?${searchParams.toString()}`
logger.info(`Searching in space ${spaceKey} with CQL: ${cql}`)
const response = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
const errorData = await response.json().catch(() => null)
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: JSON.stringify(errorData, null, 2),
})
const errorMessage = errorData?.message || `Failed to search in space (${response.status})`
return NextResponse.json({ error: errorMessage }, { status: response.status })
}
const data = await response.json()
const results = (data.results || []).map((result: any) => ({
id: result.content?.id ?? result.id,
title: result.content?.title ?? result.title,
type: result.content?.type ?? result.type,
status: result.content?.status ?? null,
url: result.url ?? result._links?.webui ?? '',
excerpt: result.excerpt ?? '',
lastModified: result.lastModified ?? null,
}))
return NextResponse.json({
results,
spaceKey,
totalSize: data.totalSize ?? results.length,
})
} catch (error) {
logger.error('Error searching in space:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -42,8 +42,10 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
const escapeCqlValue = (value: string) => value.replace(/"/g, '\\"')
const searchParams = new URLSearchParams({
cql: `text ~ "${query}"`,
cql: `text ~ "${escapeCqlValue(query)}"`,
limit: limit.toString(),
})
@@ -70,13 +72,27 @@ export async function POST(request: NextRequest) {
const data = await response.json()
const results = (data.results || []).map((result: any) => ({
id: result.content?.id || result.id,
title: result.content?.title || result.title,
type: result.content?.type || result.type,
url: result.url || result._links?.webui || '',
excerpt: result.excerpt || '',
}))
const results = (data.results || []).map((result: any) => {
const spaceData = result.resultGlobalContainer || result.content?.space
return {
id: result.content?.id || result.id,
title: result.content?.title || result.title,
type: result.content?.type || result.type,
url: result.url || result._links?.webui || '',
excerpt: result.excerpt || '',
status: result.content?.status ?? null,
spaceKey: result.resultGlobalContainer?.key ?? result.content?.space?.key ?? null,
space: spaceData
? {
id: spaceData.id ?? null,
key: spaceData.key ?? null,
name: spaceData.name ?? spaceData.title ?? null,
}
: null,
lastModified: result.lastModified ?? result.content?.history?.lastUpdated?.when ?? null,
entityType: result.entityType ?? null,
}
})
return NextResponse.json({ results })
} catch (error) {

View File

@@ -0,0 +1,124 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
import { getConfluenceCloudId } from '@/tools/confluence/utils'
const logger = createLogger('ConfluenceSpaceBlogPostsAPI')
export const dynamic = 'force-dynamic'
/**
* List all blog posts in a specific Confluence space.
* Uses GET /wiki/api/v2/spaces/{id}/blogposts
*/
export async function POST(request: NextRequest) {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const {
domain,
accessToken,
spaceId,
cloudId: providedCloudId,
limit = 25,
status,
bodyFormat,
cursor,
} = body
if (!domain) {
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
}
if (!accessToken) {
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}
if (!spaceId) {
return NextResponse.json({ error: 'Space ID is required' }, { status: 400 })
}
const spaceIdValidation = validateAlphanumericId(spaceId, 'spaceId', 255)
if (!spaceIdValidation.isValid) {
return NextResponse.json({ error: spaceIdValidation.error }, { status: 400 })
}
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
const queryParams = new URLSearchParams()
queryParams.append('limit', String(Math.min(limit, 250)))
if (status) {
queryParams.append('status', status)
}
if (bodyFormat) {
queryParams.append('body-format', bodyFormat)
}
if (cursor) {
queryParams.append('cursor', cursor)
}
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/spaces/${spaceId}/blogposts?${queryParams.toString()}`
logger.info(`Fetching blog posts in space ${spaceId}`)
const response = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
const errorData = await response.json().catch(() => null)
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: JSON.stringify(errorData, null, 2),
})
const errorMessage =
errorData?.message || `Failed to list blog posts in space (${response.status})`
return NextResponse.json({ error: errorMessage }, { status: response.status })
}
const data = await response.json()
const blogPosts = (data.results || []).map((post: any) => ({
id: post.id,
title: post.title,
status: post.status ?? null,
spaceId: post.spaceId ?? null,
authorId: post.authorId ?? null,
createdAt: post.createdAt ?? null,
version: post.version ?? null,
body: post.body ?? null,
webUrl: post._links?.webui ?? null,
}))
return NextResponse.json({
blogPosts,
nextCursor: data._links?.next
? new URL(data._links.next, 'https://placeholder').searchParams.get('cursor')
: null,
})
} catch (error) {
logger.error('Error listing blog posts in space:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,125 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
import { getConfluenceCloudId } from '@/tools/confluence/utils'
const logger = createLogger('ConfluenceSpacePagesAPI')
export const dynamic = 'force-dynamic'
/**
* List all pages in a specific Confluence space.
* Uses GET /wiki/api/v2/spaces/{id}/pages
*/
export async function POST(request: NextRequest) {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const {
domain,
accessToken,
spaceId,
cloudId: providedCloudId,
limit = 50,
status,
bodyFormat,
cursor,
} = body
if (!domain) {
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
}
if (!accessToken) {
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}
if (!spaceId) {
return NextResponse.json({ error: 'Space ID is required' }, { status: 400 })
}
const spaceIdValidation = validateAlphanumericId(spaceId, 'spaceId', 255)
if (!spaceIdValidation.isValid) {
return NextResponse.json({ error: spaceIdValidation.error }, { status: 400 })
}
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
const queryParams = new URLSearchParams()
queryParams.append('limit', String(Math.min(limit, 250)))
if (status) {
queryParams.append('status', status)
}
if (bodyFormat) {
queryParams.append('body-format', bodyFormat)
}
if (cursor) {
queryParams.append('cursor', cursor)
}
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/spaces/${spaceId}/pages?${queryParams.toString()}`
logger.info(`Fetching pages in space ${spaceId}`)
const response = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
const errorData = await response.json().catch(() => null)
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: JSON.stringify(errorData, null, 2),
})
const errorMessage =
errorData?.message || `Failed to list pages in space (${response.status})`
return NextResponse.json({ error: errorMessage }, { status: response.status })
}
const data = await response.json()
const pages = (data.results || []).map((page: any) => ({
id: page.id,
title: page.title,
status: page.status ?? null,
spaceId: page.spaceId ?? null,
parentId: page.parentId ?? null,
authorId: page.authorId ?? null,
createdAt: page.createdAt ?? null,
version: page.version ?? null,
body: page.body ?? null,
webUrl: page._links?.webui ?? null,
}))
return NextResponse.json({
pages,
nextCursor: data._links?.next
? new URL(data._links.next, 'https://placeholder').searchParams.get('cursor')
: null,
})
} catch (error) {
logger.error('Error listing pages in space:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -21,6 +21,7 @@ export async function GET(request: NextRequest) {
const accessToken = searchParams.get('accessToken')
const providedCloudId = searchParams.get('cloudId')
const limit = searchParams.get('limit') || '25'
const cursor = searchParams.get('cursor')
if (!domain) {
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
@@ -37,7 +38,12 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/spaces?limit=${limit}`
const queryParams = new URLSearchParams()
queryParams.append('limit', String(Math.min(Number(limit), 250)))
if (cursor) {
queryParams.append('cursor', cursor)
}
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/spaces?${queryParams.toString()}`
const response = await fetch(url, {
method: 'GET',
@@ -67,9 +73,18 @@ export async function GET(request: NextRequest) {
key: space.key,
type: space.type,
status: space.status,
authorId: space.authorId ?? null,
createdAt: space.createdAt ?? null,
homepageId: space.homepageId ?? null,
description: space.description ?? null,
}))
return NextResponse.json({ spaces })
return NextResponse.json({
spaces,
nextCursor: data._links?.next
? new URL(data._links.next, 'https://placeholder').searchParams.get('cursor')
: null,
})
} catch (error) {
logger.error('Error listing Confluence spaces:', error)
return NextResponse.json(

View File

@@ -3,8 +3,8 @@
import Image from 'next/image'
import Link from 'next/link'
import { GithubIcon } from '@/components/icons'
import { useBrandConfig } from '@/lib/branding/branding'
import { inter } from '@/app/_styles/fonts/inter/inter'
import { useBrandConfig } from '@/ee/whitelabeling'
interface ChatHeaderProps {
chatConfig: {

View File

@@ -1,8 +1,8 @@
'use client'
import Image from 'next/image'
import { useBrandConfig } from '@/lib/branding/branding'
import { inter } from '@/app/_styles/fonts/inter/inter'
import { useBrandConfig } from '@/ee/whitelabeling'
export function PoweredBySim() {
const brandConfig = useBrandConfig()

View File

@@ -2,9 +2,12 @@ import type { Metadata, Viewport } from 'next'
import Script from 'next/script'
import { PublicEnvScript } from 'next-runtime-env'
import { BrandedLayout } from '@/components/branded-layout'
import { generateThemeCSS } from '@/lib/branding/inject-theme'
import { generateBrandedMetadata, generateStructuredData } from '@/lib/branding/metadata'
import { PostHogProvider } from '@/app/_shell/providers/posthog-provider'
import {
generateBrandedMetadata,
generateStructuredData,
generateThemeCSS,
} from '@/ee/whitelabeling'
import '@/app/_styles/globals.css'
import { OneDollarStats } from '@/components/analytics/onedollarstats'
import { isReactGrabEnabled, isReactScanEnabled } from '@/lib/core/config/feature-flags'

View File

@@ -1,5 +1,5 @@
import type { MetadataRoute } from 'next'
import { getBrandConfig } from '@/lib/branding/branding'
import { getBrandConfig } from '@/ee/whitelabeling'
export default function manifest(): MetadataRoute.Manifest {
const brand = getBrandConfig()

View File

@@ -24,8 +24,8 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useBrandConfig } from '@/lib/branding/branding'
import Nav from '@/app/(landing)/components/nav/nav'
import { useBrandConfig } from '@/ee/whitelabeling'
import type { ResumeStatus } from '@/executor/types'
interface ResumeLinks {

View File

@@ -74,6 +74,12 @@ const SCOPE_DESCRIPTIONS: Record<string, string> = {
'write:label:confluence': 'Add and remove labels',
'search:confluence': 'Search Confluence content',
'readonly:content.attachment:confluence': 'View attachments',
'read:blogpost:confluence': 'View Confluence blog posts',
'write:blogpost:confluence': 'Create and update Confluence blog posts',
'read:content.property:confluence': 'View properties on Confluence content',
'write:content.property:confluence': 'Create and manage content properties',
'read:hierarchical-content:confluence': 'View page hierarchy (children and ancestors)',
'read:content.metadata:confluence': 'View content metadata (required for ancestors)',
'read:me': 'Read profile information',
'database.read': 'Read database',
'database.write': 'Write to database',
@@ -358,6 +364,7 @@ export function OAuthRequiredModal({
logger.info('Linking OAuth2:', {
providerId,
requiredScopes,
hasNewScopes: newScopes.length > 0,
})
if (providerId === 'trello') {

View File

@@ -100,7 +100,7 @@ const BlockRow = memo(function BlockRow({
>
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
<div
className='relative flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center overflow-hidden rounded-[4px]'
className='flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center rounded-[4px]'
style={{ background: bgColor }}
>
{BlockIcon && <BlockIcon className='h-[9px] w-[9px] text-white' />}
@@ -276,7 +276,7 @@ const SubflowNodeRow = memo(function SubflowNodeRow({
>
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
<div
className='relative flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center overflow-hidden rounded-[4px]'
className='flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center rounded-[4px]'
style={{ background: bgColor }}
>
{BlockIcon && <BlockIcon className='h-[9px] w-[9px] text-white' />}

View File

@@ -19,11 +19,11 @@ import {
import { Input, Skeleton } from '@/components/ui'
import { signOut, useSession } from '@/lib/auth/auth-client'
import { ANONYMOUS_USER_ID } from '@/lib/auth/constants'
import { useBrandConfig } from '@/lib/branding/branding'
import { getEnv, isTruthy } from '@/lib/core/config/env'
import { isHosted } from '@/lib/core/config/feature-flags'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { useProfilePictureUpload } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/hooks/use-profile-picture-upload'
import { useBrandConfig } from '@/ee/whitelabeling'
import { useGeneralSettings, useUpdateGeneralSetting } from '@/hooks/queries/general-settings'
import { useUpdateUserProfile, useUserProfile } from '@/hooks/queries/user-profile'
import { clearUserData } from '@/stores'

View File

@@ -397,7 +397,7 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
return () => window.clearInterval(interval)
}, [isHovered, pillCount, startAnimationIndex])
if (isLoading) {
if (isLoading && !subscriptionData) {
return (
<div className='flex flex-shrink-0 flex-col gap-[8px] border-t px-[13.5px] pt-[8px] pb-[10px]'>
<div className='flex h-[18px] items-center justify-between'>

View File

@@ -75,6 +75,12 @@ export const ConfluenceBlock: BlockConfig<ConfluenceResponse> = {
'search:confluence',
'read:me',
'offline_access',
'read:blogpost:confluence',
'write:blogpost:confluence',
'read:content.property:confluence',
'write:content.property:confluence',
'read:hierarchical-content:confluence',
'read:content.metadata:confluence',
],
placeholder: 'Select Confluence account',
required: true,
@@ -334,6 +340,7 @@ export const ConfluenceBlock: BlockConfig<ConfluenceResponse> = {
ts: { type: 'string', description: 'Timestamp' },
pageId: { type: 'string', description: 'Page identifier' },
content: { type: 'string', description: 'Page content' },
body: { type: 'json', description: 'Page body with storage format' },
title: { type: 'string', description: 'Page title' },
url: { type: 'string', description: 'Page or resource URL' },
success: { type: 'boolean', description: 'Operation success status' },
@@ -371,31 +378,46 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
title: 'Operation',
type: 'dropdown',
options: [
// Page Operations
{ label: 'Read Page', id: 'read' },
{ label: 'Create Page', id: 'create' },
{ label: 'Update Page', id: 'update' },
{ label: 'Delete Page', id: 'delete' },
{ label: 'List Pages in Space', id: 'list_pages_in_space' },
{ label: 'Get Page Children', id: 'get_page_children' },
{ label: 'Get Page Ancestors', id: 'get_page_ancestors' },
// Version Operations
{ label: 'List Page Versions', id: 'list_page_versions' },
{ label: 'Get Page Version', id: 'get_page_version' },
// Page Property Operations
{ label: 'List Page Properties', id: 'list_page_properties' },
{ label: 'Create Page Property', id: 'create_page_property' },
// Search Operations
{ label: 'Search Content', id: 'search' },
{ label: 'Search in Space', id: 'search_in_space' },
// Blog Post Operations
{ label: 'List Blog Posts', id: 'list_blogposts' },
{ label: 'Get Blog Post', id: 'get_blogpost' },
{ label: 'Create Blog Post', id: 'create_blogpost' },
{ label: 'List Blog Posts in Space', id: 'list_blogposts_in_space' },
// Comment Operations
{ label: 'Create Comment', id: 'create_comment' },
{ label: 'List Comments', id: 'list_comments' },
{ label: 'Update Comment', id: 'update_comment' },
{ label: 'Delete Comment', id: 'delete_comment' },
// Attachment Operations
{ label: 'Upload Attachment', id: 'upload_attachment' },
{ label: 'List Attachments', id: 'list_attachments' },
{ label: 'Delete Attachment', id: 'delete_attachment' },
// Label Operations
{ label: 'List Labels', id: 'list_labels' },
{ label: 'Add Label', id: 'add_label' },
// Space Operations
{ label: 'Get Space', id: 'get_space' },
{ label: 'List Spaces', id: 'list_spaces' },
],
value: () => 'read',
},
{
id: 'domain',
title: 'Domain',
type: 'short-input',
placeholder: 'Enter Confluence domain (e.g., simstudio.atlassian.net)',
required: true,
},
{
id: 'credential',
title: 'Confluence Account',
@@ -424,10 +446,23 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
'search:confluence',
'read:me',
'offline_access',
'read:blogpost:confluence',
'write:blogpost:confluence',
'read:content.property:confluence',
'write:content.property:confluence',
'read:hierarchical-content:confluence',
'read:content.metadata:confluence',
],
placeholder: 'Select Confluence account',
required: true,
},
{
id: 'domain',
title: 'Domain',
type: 'short-input',
placeholder: 'Enter Confluence domain (e.g., simstudio.atlassian.net)',
required: true,
},
{
id: 'pageId',
title: 'Select Page',
@@ -437,6 +472,20 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
placeholder: 'Select Confluence page',
dependsOn: ['credential', 'domain'],
mode: 'basic',
condition: {
field: 'operation',
value: [
'list_pages_in_space',
'list_blogposts',
'get_blogpost',
'list_blogposts_in_space',
'search',
'search_in_space',
'get_space',
'list_spaces',
],
not: true,
},
},
{
id: 'manualPageId',
@@ -445,6 +494,20 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
canonicalParamId: 'pageId',
placeholder: 'Enter Confluence page ID',
mode: 'advanced',
condition: {
field: 'operation',
value: [
'list_pages_in_space',
'list_blogposts',
'get_blogpost',
'list_blogposts_in_space',
'search',
'search_in_space',
'get_space',
'list_spaces',
],
not: true,
},
},
{
id: 'spaceId',
@@ -452,21 +515,63 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
type: 'short-input',
placeholder: 'Enter Confluence space ID',
required: true,
condition: { field: 'operation', value: ['create', 'get_space'] },
condition: {
field: 'operation',
value: [
'create',
'get_space',
'list_pages_in_space',
'search_in_space',
'create_blogpost',
'list_blogposts_in_space',
],
},
},
{
id: 'blogPostId',
title: 'Blog Post ID',
type: 'short-input',
placeholder: 'Enter blog post ID',
required: true,
condition: { field: 'operation', value: 'get_blogpost' },
},
{
id: 'versionNumber',
title: 'Version Number',
type: 'short-input',
placeholder: 'Enter version number',
required: true,
condition: { field: 'operation', value: 'get_page_version' },
},
{
id: 'propertyKey',
title: 'Property Key',
type: 'short-input',
placeholder: 'Enter property key/name',
required: true,
condition: { field: 'operation', value: 'create_page_property' },
},
{
id: 'propertyValue',
title: 'Property Value',
type: 'long-input',
placeholder: 'Enter property value (JSON supported)',
required: true,
condition: { field: 'operation', value: 'create_page_property' },
},
{
id: 'title',
title: 'Title',
type: 'short-input',
placeholder: 'Enter title for the page',
condition: { field: 'operation', value: ['create', 'update'] },
placeholder: 'Enter title',
condition: { field: 'operation', value: ['create', 'update', 'create_blogpost'] },
},
{
id: 'content',
title: 'Content',
type: 'long-input',
placeholder: 'Enter content for the page',
condition: { field: 'operation', value: ['create', 'update'] },
placeholder: 'Enter content',
condition: { field: 'operation', value: ['create', 'update', 'create_blogpost'] },
},
{
id: 'parentId',
@@ -481,7 +586,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
type: 'short-input',
placeholder: 'Enter search query',
required: true,
condition: { field: 'operation', value: 'search' },
condition: { field: 'operation', value: ['search', 'search_in_space'] },
},
{
id: 'comment',
@@ -545,40 +650,140 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
type: 'short-input',
placeholder: 'Enter label name',
required: true,
condition: { field: 'operation', value: ['add_label', 'remove_label'] },
condition: { field: 'operation', value: 'add_label' },
},
{
id: 'labelPrefix',
title: 'Label Prefix',
type: 'dropdown',
options: [
{ label: 'Global (default)', id: 'global' },
{ label: 'My', id: 'my' },
{ label: 'Team', id: 'team' },
{ label: 'System', id: 'system' },
],
value: () => 'global',
condition: { field: 'operation', value: 'add_label' },
},
{
id: 'blogPostStatus',
title: 'Status',
type: 'dropdown',
options: [
{ label: 'Published (current)', id: 'current' },
{ label: 'Draft', id: 'draft' },
],
value: () => 'current',
condition: { field: 'operation', value: 'create_blogpost' },
},
{
id: 'purge',
title: 'Permanently Delete',
type: 'switch',
condition: { field: 'operation', value: 'delete' },
},
{
id: 'bodyFormat',
title: 'Body Format',
type: 'dropdown',
options: [
{ label: 'Storage (default)', id: 'storage' },
{ label: 'Atlas Doc Format', id: 'atlas_doc_format' },
{ label: 'View', id: 'view' },
{ label: 'Export View', id: 'export_view' },
],
value: () => 'storage',
condition: { field: 'operation', value: 'list_comments' },
},
{
id: 'limit',
title: 'Limit',
type: 'short-input',
placeholder: 'Enter maximum number of results (default: 25)',
placeholder: 'Enter maximum number of results (default: 50, max: 250)',
condition: {
field: 'operation',
value: ['search', 'list_comments', 'list_attachments', 'list_spaces'],
value: [
'search',
'search_in_space',
'list_comments',
'list_attachments',
'list_spaces',
'list_pages_in_space',
'list_blogposts',
'list_blogposts_in_space',
'get_page_children',
'list_page_versions',
'list_page_properties',
'list_labels',
],
},
},
{
id: 'cursor',
title: 'Pagination Cursor',
type: 'short-input',
placeholder: 'Enter cursor from previous response (optional)',
condition: {
field: 'operation',
value: [
'list_comments',
'list_attachments',
'list_spaces',
'list_pages_in_space',
'list_blogposts',
'list_blogposts_in_space',
'get_page_children',
'list_page_versions',
'list_page_properties',
'list_labels',
],
},
},
],
tools: {
access: [
// Page Tools
'confluence_retrieve',
'confluence_update',
'confluence_create_page',
'confluence_delete_page',
'confluence_list_pages_in_space',
'confluence_get_page_children',
'confluence_get_page_ancestors',
// Version Tools
'confluence_list_page_versions',
'confluence_get_page_version',
// Property Tools
'confluence_list_page_properties',
'confluence_create_page_property',
// Search Tools
'confluence_search',
'confluence_search_in_space',
// Blog Post Tools
'confluence_list_blogposts',
'confluence_get_blogpost',
'confluence_create_blogpost',
'confluence_list_blogposts_in_space',
// Comment Tools
'confluence_create_comment',
'confluence_list_comments',
'confluence_update_comment',
'confluence_delete_comment',
// Attachment Tools
'confluence_upload_attachment',
'confluence_list_attachments',
'confluence_delete_attachment',
// Label Tools
'confluence_list_labels',
'confluence_add_label',
// Space Tools
'confluence_get_space',
'confluence_list_spaces',
],
config: {
tool: (params) => {
switch (params.operation) {
// Page Operations
case 'read':
return 'confluence_retrieve'
case 'create':
@@ -587,8 +792,37 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
return 'confluence_update'
case 'delete':
return 'confluence_delete_page'
case 'list_pages_in_space':
return 'confluence_list_pages_in_space'
case 'get_page_children':
return 'confluence_get_page_children'
case 'get_page_ancestors':
return 'confluence_get_page_ancestors'
// Version Operations
case 'list_page_versions':
return 'confluence_list_page_versions'
case 'get_page_version':
return 'confluence_get_page_version'
// Property Operations
case 'list_page_properties':
return 'confluence_list_page_properties'
case 'create_page_property':
return 'confluence_create_page_property'
// Search Operations
case 'search':
return 'confluence_search'
case 'search_in_space':
return 'confluence_search_in_space'
// Blog Post Operations
case 'list_blogposts':
return 'confluence_list_blogposts'
case 'get_blogpost':
return 'confluence_get_blogpost'
case 'create_blogpost':
return 'confluence_create_blogpost'
case 'list_blogposts_in_space':
return 'confluence_list_blogposts_in_space'
// Comment Operations
case 'create_comment':
return 'confluence_create_comment'
case 'list_comments':
@@ -597,14 +831,19 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
return 'confluence_update_comment'
case 'delete_comment':
return 'confluence_delete_comment'
// Attachment Operations
case 'upload_attachment':
return 'confluence_upload_attachment'
case 'list_attachments':
return 'confluence_list_attachments'
case 'delete_attachment':
return 'confluence_delete_attachment'
// Label Operations
case 'list_labels':
return 'confluence_list_labels'
case 'add_label':
return 'confluence_add_label'
// Space Operations
case 'get_space':
return 'confluence_get_space'
case 'list_spaces':
@@ -624,6 +863,15 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
attachmentFile,
attachmentFileName,
attachmentComment,
blogPostId,
versionNumber,
propertyKey,
propertyValue,
labelPrefix,
blogPostStatus,
purge,
bodyFormat,
cursor,
...rest
} = params
@@ -638,9 +886,23 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
'list_attachments',
'list_labels',
'upload_attachment',
'add_label',
'get_page_children',
'get_page_ancestors',
'list_page_versions',
'get_page_version',
'list_page_properties',
'create_page_property',
]
const requiresSpaceId = ['create', 'get_space']
const requiresSpaceId = [
'create',
'get_space',
'list_pages_in_space',
'search_in_space',
'create_blogpost',
'list_blogposts_in_space',
]
if (requiresPageId.includes(operation) && !effectivePageId) {
throw new Error('Page ID is required. Please select a page or enter a page ID manually.')
@@ -650,6 +912,91 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
throw new Error('Space ID is required for this operation.')
}
if (operation === 'get_blogpost' && !blogPostId) {
throw new Error('Blog Post ID is required for this operation.')
}
if (operation === 'get_page_version' && !versionNumber) {
throw new Error('Version number is required for this operation.')
}
if (operation === 'add_label') {
return {
credential,
pageId: effectivePageId,
operation,
prefix: labelPrefix || 'global',
...rest,
}
}
if (operation === 'create_blogpost') {
return {
credential,
operation,
status: blogPostStatus || 'current',
...rest,
}
}
if (operation === 'delete') {
return {
credential,
pageId: effectivePageId,
operation,
purge: purge || false,
...rest,
}
}
if (operation === 'list_comments') {
return {
credential,
pageId: effectivePageId,
operation,
bodyFormat: bodyFormat || 'storage',
cursor: cursor || undefined,
...rest,
}
}
// Operations that support cursor pagination
const supportsCursor = [
'list_attachments',
'list_spaces',
'list_pages_in_space',
'list_blogposts',
'list_blogposts_in_space',
'get_page_children',
'list_page_versions',
'list_page_properties',
'list_labels',
]
if (supportsCursor.includes(operation) && cursor) {
return {
credential,
pageId: effectivePageId || undefined,
operation,
cursor,
...rest,
}
}
if (operation === 'create_page_property') {
if (!propertyKey) {
throw new Error('Property key is required for this operation.')
}
return {
credential,
pageId: effectivePageId,
operation,
key: propertyKey,
value: propertyValue,
...rest,
}
}
if (operation === 'upload_attachment') {
const fileInput = attachmentFileUpload || attachmentFileReference || attachmentFile
const normalizedFile = normalizeFileInput(fileInput, { single: true })
@@ -670,6 +1017,8 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
return {
credential,
pageId: effectivePageId || undefined,
blogPostId: blogPostId || undefined,
versionNumber: versionNumber ? Number.parseInt(String(versionNumber), 10) : undefined,
operation,
...rest,
}
@@ -683,8 +1032,12 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
pageId: { type: 'string', description: 'Page identifier' },
manualPageId: { type: 'string', description: 'Manual page identifier' },
spaceId: { type: 'string', description: 'Space identifier' },
title: { type: 'string', description: 'Page title' },
content: { type: 'string', description: 'Page content' },
blogPostId: { type: 'string', description: 'Blog post identifier' },
versionNumber: { type: 'number', description: 'Page version number' },
propertyKey: { type: 'string', description: 'Property key/name' },
propertyValue: { type: 'json', description: 'Property value (JSON)' },
title: { type: 'string', description: 'Page or blog post title' },
content: { type: 'string', description: 'Page or blog post content' },
parentId: { type: 'string', description: 'Parent page identifier' },
query: { type: 'string', description: 'Search query' },
comment: { type: 'string', description: 'Comment text' },
@@ -696,6 +1049,62 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
attachmentFileName: { type: 'string', description: 'Custom file name for attachment' },
attachmentComment: { type: 'string', description: 'Comment for the attachment' },
labelName: { type: 'string', description: 'Label name' },
labelPrefix: { type: 'string', description: 'Label prefix (global, my, team, system)' },
blogPostStatus: { type: 'string', description: 'Blog post status (current or draft)' },
purge: { type: 'boolean', description: 'Permanently delete instead of moving to trash' },
bodyFormat: { type: 'string', description: 'Body format for comments' },
limit: { type: 'number', description: 'Maximum number of results' },
cursor: { type: 'string', description: 'Pagination cursor from previous response' },
},
outputs: {
ts: { type: 'string', description: 'Timestamp' },
pageId: { type: 'string', description: 'Page identifier' },
content: { type: 'string', description: 'Page content' },
body: { type: 'json', description: 'Page body with storage format' },
title: { type: 'string', description: 'Page title' },
url: { type: 'string', description: 'Page or resource URL' },
success: { type: 'boolean', description: 'Operation success status' },
deleted: { type: 'boolean', description: 'Deletion status' },
added: { type: 'boolean', description: 'Addition status' },
removed: { type: 'boolean', description: 'Removal status' },
updated: { type: 'boolean', description: 'Update status' },
// Search & List Results
results: { type: 'array', description: 'Search results' },
pages: { type: 'array', description: 'List of pages' },
children: { type: 'array', description: 'List of child pages' },
ancestors: { type: 'array', description: 'List of ancestor pages' },
// Comment Results
comments: { type: 'array', description: 'List of comments' },
commentId: { type: 'string', description: 'Comment identifier' },
// Attachment Results
attachments: { type: 'array', description: 'List of attachments' },
attachmentId: { type: 'string', description: 'Attachment identifier' },
fileSize: { type: 'number', description: 'Attachment file size in bytes' },
mediaType: { type: 'string', description: 'Attachment MIME type' },
downloadUrl: { type: 'string', description: 'Attachment download URL' },
// Label Results
labels: { type: 'array', description: 'List of labels' },
labelName: { type: 'string', description: 'Label name' },
// Space Results
spaces: { type: 'array', description: 'List of spaces' },
spaceId: { type: 'string', description: 'Space identifier' },
name: { type: 'string', description: 'Space name' },
key: { type: 'string', description: 'Space key' },
type: { type: 'string', description: 'Space or content type' },
status: { type: 'string', description: 'Space status' },
// Blog Post Results
blogPosts: { type: 'array', description: 'List of blog posts' },
blogPostId: { type: 'string', description: 'Blog post identifier' },
// Version Results
versions: { type: 'array', description: 'List of page versions' },
version: { type: 'json', description: 'Version information' },
versionNumber: { type: 'number', description: 'Version number' },
// Property Results
properties: { type: 'array', description: 'List of page properties' },
propertyId: { type: 'string', description: 'Property identifier' },
propertyKey: { type: 'string', description: 'Property key' },
propertyValue: { type: 'json', description: 'Property value' },
// Pagination
nextCursor: { type: 'string', description: 'Cursor for fetching next page of results' },
},
}

View File

@@ -1,7 +1,7 @@
'use client'
import { useEffect } from 'react'
import { getBrandConfig } from '@/lib/branding/branding'
import { getBrandConfig } from '@/ee/whitelabeling'
interface BrandedLayoutProps {
children: React.ReactNode

View File

@@ -1,7 +1,7 @@
import { Section, Text } from '@react-email/components'
import { baseStyles } from '@/components/emails/_styles'
import { EmailLayout } from '@/components/emails/components'
import { getBrandConfig } from '@/lib/branding/branding'
import { getBrandConfig } from '@/ee/whitelabeling'
interface OTPVerificationEmailProps {
otp: string

View File

@@ -1,7 +1,7 @@
import { Link, Text } from '@react-email/components'
import { baseStyles } from '@/components/emails/_styles'
import { EmailLayout } from '@/components/emails/components'
import { getBrandConfig } from '@/lib/branding/branding'
import { getBrandConfig } from '@/ee/whitelabeling'
interface ResetPasswordEmailProps {
username?: string

View File

@@ -1,8 +1,8 @@
import { Link, Text } from '@react-email/components'
import { baseStyles } from '@/components/emails/_styles'
import { EmailLayout } from '@/components/emails/components'
import { getBrandConfig } from '@/lib/branding/branding'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { getBrandConfig } from '@/ee/whitelabeling'
interface WelcomeEmailProps {
userName?: string

View File

@@ -1,8 +1,8 @@
import { Link, Section, Text } from '@react-email/components'
import { baseStyles, colors } from '@/components/emails/_styles'
import { EmailLayout } from '@/components/emails/components'
import { getBrandConfig } from '@/lib/branding/branding'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { getBrandConfig } from '@/ee/whitelabeling'
interface CreditPurchaseEmailProps {
userName?: string

View File

@@ -1,8 +1,8 @@
import { Link, Text } from '@react-email/components'
import { baseStyles } from '@/components/emails/_styles'
import { EmailLayout } from '@/components/emails/components'
import { getBrandConfig } from '@/lib/branding/branding'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { getBrandConfig } from '@/ee/whitelabeling'
interface EnterpriseSubscriptionEmailProps {
userName?: string

View File

@@ -1,7 +1,7 @@
import { Link, Section, Text } from '@react-email/components'
import { baseStyles, colors, typography } from '@/components/emails/_styles'
import { EmailLayout } from '@/components/emails/components'
import { getBrandConfig } from '@/lib/branding/branding'
import { getBrandConfig } from '@/ee/whitelabeling'
interface FreeTierUpgradeEmailProps {
userName?: string

View File

@@ -1,7 +1,7 @@
import { Link, Section, Text } from '@react-email/components'
import { baseStyles, colors } from '@/components/emails/_styles'
import { EmailLayout } from '@/components/emails/components'
import { getBrandConfig } from '@/lib/branding/branding'
import { getBrandConfig } from '@/ee/whitelabeling'
interface PaymentFailedEmailProps {
userName?: string

View File

@@ -1,8 +1,8 @@
import { Link, Text } from '@react-email/components'
import { baseStyles } from '@/components/emails/_styles'
import { EmailLayout } from '@/components/emails/components'
import { getBrandConfig } from '@/lib/branding/branding'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { getBrandConfig } from '@/ee/whitelabeling'
interface PlanWelcomeEmailProps {
planName: 'Pro' | 'Team'

View File

@@ -1,7 +1,7 @@
import { Link, Section, Text } from '@react-email/components'
import { baseStyles } from '@/components/emails/_styles'
import { EmailLayout } from '@/components/emails/components'
import { getBrandConfig } from '@/lib/branding/branding'
import { getBrandConfig } from '@/ee/whitelabeling'
interface UsageThresholdEmailProps {
userName?: string

View File

@@ -2,8 +2,8 @@ import { Text } from '@react-email/components'
import { format } from 'date-fns'
import { baseStyles } from '@/components/emails/_styles'
import { EmailLayout } from '@/components/emails/components'
import { getBrandConfig } from '@/lib/branding/branding'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { getBrandConfig } from '@/ee/whitelabeling'
interface CareersConfirmationEmailProps {
name: string

View File

@@ -1,8 +1,8 @@
import { Container, Img, Link, Section } from '@react-email/components'
import { baseStyles, colors, spacing, typography } from '@/components/emails/_styles'
import { getBrandConfig } from '@/lib/branding/branding'
import { isHosted } from '@/lib/core/config/feature-flags'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { getBrandConfig } from '@/ee/whitelabeling'
interface EmailFooterProps {
baseUrl?: string

View File

@@ -1,8 +1,8 @@
import { Body, Container, Head, Html, Img, Preview, Section } from '@react-email/components'
import { baseStyles } from '@/components/emails/_styles'
import { EmailFooter } from '@/components/emails/components/email-footer'
import { getBrandConfig } from '@/lib/branding/branding'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { getBrandConfig } from '@/ee/whitelabeling'
interface EmailLayoutProps {
/** Preview text shown in email client list view */

View File

@@ -1,7 +1,7 @@
import { Link, Text } from '@react-email/components'
import { baseStyles } from '@/components/emails/_styles'
import { EmailLayout } from '@/components/emails/components'
import { getBrandConfig } from '@/lib/branding/branding'
import { getBrandConfig } from '@/ee/whitelabeling'
interface WorkspaceInvitation {
workspaceId: string

View File

@@ -2,8 +2,8 @@ import { Link, Text } from '@react-email/components'
import { createLogger } from '@sim/logger'
import { baseStyles } from '@/components/emails/_styles'
import { EmailLayout } from '@/components/emails/components'
import { getBrandConfig } from '@/lib/branding/branding'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { getBrandConfig } from '@/ee/whitelabeling'
interface InvitationEmailProps {
inviterName?: string

View File

@@ -1,7 +1,7 @@
import { Link, Text } from '@react-email/components'
import { baseStyles } from '@/components/emails/_styles'
import { EmailLayout } from '@/components/emails/components'
import { getBrandConfig } from '@/lib/branding/branding'
import { getBrandConfig } from '@/ee/whitelabeling'
interface PollingGroupInvitationEmailProps {
inviterName?: string

View File

@@ -2,8 +2,8 @@ import { Link, Text } from '@react-email/components'
import { createLogger } from '@sim/logger'
import { baseStyles } from '@/components/emails/_styles'
import { EmailLayout } from '@/components/emails/components'
import { getBrandConfig } from '@/lib/branding/branding'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { getBrandConfig } from '@/ee/whitelabeling'
const logger = createLogger('WorkspaceInvitationEmail')

View File

@@ -1,7 +1,7 @@
import { Link, Section, Text } from '@react-email/components'
import { baseStyles } from '@/components/emails/_styles'
import { EmailLayout } from '@/components/emails/components'
import { getBrandConfig } from '@/lib/branding/branding'
import { getBrandConfig } from '@/ee/whitelabeling'
/**
* Serialized rate limit status for email payloads.

View File

@@ -1,4 +1,4 @@
import { getBrandConfig } from '@/lib/branding/branding'
import { getBrandConfig } from '@/ee/whitelabeling'
/** Email subject type for all supported email templates */
export type EmailSubjectType =

View File

@@ -27,7 +27,7 @@ under the following terms:
3. ENTERPRISE SUBSCRIPTION
Production deployment of enterprise features requires an active Sim Enterprise
subscription. Contact sales@simstudio.ai for licensing information.
subscription. Contact sales@sim.ai for licensing information.
4. DISCLAIMER
@@ -40,4 +40,4 @@ under the following terms:
IN NO EVENT SHALL SIM STUDIO, INC. BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY ARISING FROM THE USE OF THE SOFTWARE.
For questions about enterprise licensing, contact: sales@simstudio.ai
For questions about enterprise licensing, contact: sales@sim.ai

View File

@@ -7,7 +7,7 @@ for production use.
- **SSO (Single Sign-On)**: OIDC and SAML authentication integration
- **Access Control**: Permission groups for fine-grained user access management
- **Credential Sets**: Shared credential pools for email polling workflows
- **Whitelabeling**: Custom branding and theming for enterprise deployments
## Licensing

View File

@@ -0,0 +1,45 @@
import { type BrandConfig, defaultBrandConfig, type ThemeColors } from '@/lib/branding'
import { getEnv } from '@/lib/core/config/env'
export type { BrandConfig, ThemeColors }
const getThemeColors = (): ThemeColors => {
return {
primaryColor:
getEnv('NEXT_PUBLIC_BRAND_PRIMARY_COLOR') || defaultBrandConfig.theme?.primaryColor,
primaryHoverColor:
getEnv('NEXT_PUBLIC_BRAND_PRIMARY_HOVER_COLOR') ||
defaultBrandConfig.theme?.primaryHoverColor,
accentColor: getEnv('NEXT_PUBLIC_BRAND_ACCENT_COLOR') || defaultBrandConfig.theme?.accentColor,
accentHoverColor:
getEnv('NEXT_PUBLIC_BRAND_ACCENT_HOVER_COLOR') || defaultBrandConfig.theme?.accentHoverColor,
backgroundColor:
getEnv('NEXT_PUBLIC_BRAND_BACKGROUND_COLOR') || defaultBrandConfig.theme?.backgroundColor,
}
}
/**
* Get branding configuration from environment variables
* Supports runtime configuration via Docker/Kubernetes
*/
export const getBrandConfig = (): BrandConfig => {
return {
name: getEnv('NEXT_PUBLIC_BRAND_NAME') || defaultBrandConfig.name,
logoUrl: getEnv('NEXT_PUBLIC_BRAND_LOGO_URL') || defaultBrandConfig.logoUrl,
faviconUrl: getEnv('NEXT_PUBLIC_BRAND_FAVICON_URL') || defaultBrandConfig.faviconUrl,
customCssUrl: getEnv('NEXT_PUBLIC_CUSTOM_CSS_URL') || defaultBrandConfig.customCssUrl,
supportEmail: getEnv('NEXT_PUBLIC_SUPPORT_EMAIL') || defaultBrandConfig.supportEmail,
documentationUrl:
getEnv('NEXT_PUBLIC_DOCUMENTATION_URL') || defaultBrandConfig.documentationUrl,
termsUrl: getEnv('NEXT_PUBLIC_TERMS_URL') || defaultBrandConfig.termsUrl,
privacyUrl: getEnv('NEXT_PUBLIC_PRIVACY_URL') || defaultBrandConfig.privacyUrl,
theme: getThemeColors(),
}
}
/**
* Hook to use brand configuration in React components
*/
export const useBrandConfig = () => {
return getBrandConfig()
}

View File

@@ -0,0 +1,4 @@
export type { BrandConfig, ThemeColors } from './branding'
export { getBrandConfig, useBrandConfig } from './branding'
export { generateThemeCSS } from './inject-theme'
export { generateBrandedMetadata, generateStructuredData } from './metadata'

View File

@@ -1,4 +1,6 @@
// Helper to detect if background is dark
/**
* Helper to detect if background is dark
*/
function isDarkBackground(hexColor: string): boolean {
const hex = hexColor.replace('#', '')
const r = Number.parseInt(hex.substr(0, 2), 16)

View File

@@ -1,6 +1,6 @@
import type { Metadata } from 'next'
import { getBrandConfig } from '@/lib/branding/branding'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { getBrandConfig } from '@/ee/whitelabeling/branding'
/**
* Generate dynamic metadata based on brand configuration

View File

@@ -151,7 +151,8 @@ export const auth = betterAuth({
create: {
before: async (account) => {
// Only one credential per (userId, providerId) is allowed
// If user reconnects (even with a different external account), replace the existing one
// If user reconnects (even with a different external account), delete the old one
// and let Better Auth create the new one (returning false breaks account linking flow)
const existing = await db.query.account.findFirst({
where: and(
eq(schema.account.userId, account.userId),
@@ -159,101 +160,59 @@ export const auth = betterAuth({
),
})
if (existing) {
let scopeToStore = account.scope
const modifiedAccount = { ...account }
if (account.providerId === 'salesforce' && account.accessToken) {
try {
const response = await fetch(
'https://login.salesforce.com/services/oauth2/userinfo',
{
headers: {
Authorization: `Bearer ${account.accessToken}`,
},
}
)
if (response.ok) {
const data = await response.json()
if (data.profile) {
const match = data.profile.match(/^(https:\/\/[^/]+)/)
if (match && match[1] !== 'https://login.salesforce.com') {
const instanceUrl = match[1]
scopeToStore = `__sf_instance__:${instanceUrl} ${account.scope}`
}
}
if (account.providerId === 'salesforce' && account.accessToken) {
try {
const response = await fetch(
'https://login.salesforce.com/services/oauth2/userinfo',
{
headers: {
Authorization: `Bearer ${account.accessToken}`,
},
}
} catch (error) {
logger.error('Failed to fetch Salesforce instance URL', { error })
}
}
const refreshTokenExpiresAt = isMicrosoftProvider(account.providerId)
? getMicrosoftRefreshTokenExpiry()
: account.refreshTokenExpiresAt
await db
.update(schema.account)
.set({
accountId: account.accountId,
accessToken: account.accessToken,
refreshToken: account.refreshToken,
idToken: account.idToken,
accessTokenExpiresAt: account.accessTokenExpiresAt,
refreshTokenExpiresAt,
scope: scopeToStore,
updatedAt: new Date(),
})
.where(eq(schema.account.id, existing.id))
// Sync webhooks for credential sets after reconnecting
const requestId = crypto.randomUUID().slice(0, 8)
const userMemberships = await db
.select({
credentialSetId: schema.credentialSetMember.credentialSetId,
providerId: schema.credentialSet.providerId,
})
.from(schema.credentialSetMember)
.innerJoin(
schema.credentialSet,
eq(schema.credentialSetMember.credentialSetId, schema.credentialSet.id)
)
.where(
and(
eq(schema.credentialSetMember.userId, account.userId),
eq(schema.credentialSetMember.status, 'active')
)
)
for (const membership of userMemberships) {
if (membership.providerId === account.providerId) {
try {
await syncAllWebhooksForCredentialSet(membership.credentialSetId, requestId)
logger.info(
'[account.create.before] Synced webhooks after credential reconnect',
{
credentialSetId: membership.credentialSetId,
providerId: account.providerId,
}
)
} catch (error) {
logger.error(
'[account.create.before] Failed to sync webhooks after credential reconnect',
{
credentialSetId: membership.credentialSetId,
providerId: account.providerId,
error,
}
)
if (response.ok) {
const data = await response.json()
if (data.profile) {
const match = data.profile.match(/^(https:\/\/[^/]+)/)
if (match && match[1] !== 'https://login.salesforce.com') {
const instanceUrl = match[1]
modifiedAccount.scope = `__sf_instance__:${instanceUrl} ${account.scope}`
}
}
}
} catch (error) {
logger.error('Failed to fetch Salesforce instance URL', { error })
}
return false
}
return { data: account }
// Handle Microsoft refresh token expiry
if (isMicrosoftProvider(account.providerId)) {
modifiedAccount.refreshTokenExpiresAt = getMicrosoftRefreshTokenExpiry()
}
if (existing) {
// Delete the existing account so Better Auth can create the new one
// This allows account linking/re-authorization to succeed
await db.delete(schema.account).where(eq(schema.account.id, existing.id))
// Preserve the existing account ID so references (like workspace notifications) continue to work
modifiedAccount.id = existing.id
logger.info('[account.create.before] Deleted existing account for re-authorization', {
userId: account.userId,
providerId: account.providerId,
existingAccountId: existing.id,
preservingId: true,
})
// Sync webhooks for credential sets after reconnecting (in after hook)
}
return { data: modifiedAccount }
},
after: async (account) => {
try {
@@ -1687,6 +1646,12 @@ export const auth = betterAuth({
'search:confluence',
'read:me',
'offline_access',
'read:blogpost:confluence',
'write:blogpost:confluence',
'read:content.property:confluence',
'write:content.property:confluence',
'read:hierarchical-content:confluence',
'read:content.metadata:confluence',
],
responseType: 'code',
pkce: true,

View File

@@ -1,80 +0,0 @@
import { getEnv } from '@/lib/core/config/env'
export interface ThemeColors {
primaryColor?: string
primaryHoverColor?: string
accentColor?: string
accentHoverColor?: string
backgroundColor?: string
}
export interface BrandConfig {
name: string
logoUrl?: string
faviconUrl?: string
customCssUrl?: string
supportEmail?: string
documentationUrl?: string
termsUrl?: string
privacyUrl?: string
theme?: ThemeColors
}
/**
* Default brand configuration values
*/
const defaultConfig: BrandConfig = {
name: 'Sim',
logoUrl: undefined,
faviconUrl: '/favicon/favicon.ico',
customCssUrl: undefined,
supportEmail: 'help@sim.ai',
documentationUrl: undefined,
termsUrl: undefined,
privacyUrl: undefined,
theme: {
primaryColor: '#701ffc',
primaryHoverColor: '#802fff',
accentColor: '#9d54ff',
accentHoverColor: '#a66fff',
backgroundColor: '#0c0c0c',
},
}
const getThemeColors = (): ThemeColors => {
return {
primaryColor: getEnv('NEXT_PUBLIC_BRAND_PRIMARY_COLOR') || defaultConfig.theme?.primaryColor,
primaryHoverColor:
getEnv('NEXT_PUBLIC_BRAND_PRIMARY_HOVER_COLOR') || defaultConfig.theme?.primaryHoverColor,
accentColor: getEnv('NEXT_PUBLIC_BRAND_ACCENT_COLOR') || defaultConfig.theme?.accentColor,
accentHoverColor:
getEnv('NEXT_PUBLIC_BRAND_ACCENT_HOVER_COLOR') || defaultConfig.theme?.accentHoverColor,
backgroundColor:
getEnv('NEXT_PUBLIC_BRAND_BACKGROUND_COLOR') || defaultConfig.theme?.backgroundColor,
}
}
/**
* Get branding configuration from environment variables
* Supports runtime configuration via Docker/Kubernetes
*/
export const getBrandConfig = (): BrandConfig => {
return {
name: getEnv('NEXT_PUBLIC_BRAND_NAME') || defaultConfig.name,
logoUrl: getEnv('NEXT_PUBLIC_BRAND_LOGO_URL') || defaultConfig.logoUrl,
faviconUrl: getEnv('NEXT_PUBLIC_BRAND_FAVICON_URL') || defaultConfig.faviconUrl,
customCssUrl: getEnv('NEXT_PUBLIC_CUSTOM_CSS_URL') || defaultConfig.customCssUrl,
supportEmail: getEnv('NEXT_PUBLIC_SUPPORT_EMAIL') || defaultConfig.supportEmail,
documentationUrl: getEnv('NEXT_PUBLIC_DOCUMENTATION_URL') || defaultConfig.documentationUrl,
termsUrl: getEnv('NEXT_PUBLIC_TERMS_URL') || defaultConfig.termsUrl,
privacyUrl: getEnv('NEXT_PUBLIC_PRIVACY_URL') || defaultConfig.privacyUrl,
theme: getThemeColors(),
}
}
/**
* Hook to use brand configuration in React components
*/
export const useBrandConfig = () => {
return getBrandConfig()
}

View File

@@ -0,0 +1,22 @@
import type { BrandConfig } from './types'
/**
* Default brand configuration values
*/
export const defaultBrandConfig: BrandConfig = {
name: 'Sim',
logoUrl: undefined,
faviconUrl: '/favicon/favicon.ico',
customCssUrl: undefined,
supportEmail: 'help@sim.ai',
documentationUrl: undefined,
termsUrl: undefined,
privacyUrl: undefined,
theme: {
primaryColor: '#701ffc',
primaryHoverColor: '#802fff',
accentColor: '#9d54ff',
accentHoverColor: '#a66fff',
backgroundColor: '#0c0c0c',
},
}

View File

@@ -0,0 +1,2 @@
export { defaultBrandConfig } from './defaults'
export type { BrandConfig, ThemeColors } from './types'

View File

@@ -0,0 +1,19 @@
export interface ThemeColors {
primaryColor?: string
primaryHoverColor?: string
accentColor?: string
accentHoverColor?: string
backgroundColor?: string
}
export interface BrandConfig {
name: string
logoUrl?: string
faviconUrl?: string
customCssUrl?: string
supportEmail?: string
documentationUrl?: string
termsUrl?: string
privacyUrl?: string
theme?: ThemeColors
}

View File

@@ -0,0 +1,123 @@
import { TIMESTAMP_OUTPUT } from '@/tools/confluence/types'
import type { ToolConfig } from '@/tools/types'
export interface ConfluenceAddLabelParams {
accessToken: string
domain: string
pageId: string
labelName: string
prefix?: string
cloudId?: string
}
export interface ConfluenceAddLabelResponse {
success: boolean
output: {
ts: string
pageId: string
labelName: string
labelId: string
}
}
export const confluenceAddLabelTool: ToolConfig<
ConfluenceAddLabelParams,
ConfluenceAddLabelResponse
> = {
id: 'confluence_add_label',
name: 'Confluence Add Label',
description: 'Add a label to a Confluence page for organization and categorization.',
version: '1.0.0',
oauth: {
required: true,
provider: 'confluence',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'OAuth access token for Confluence',
},
domain: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Your Confluence domain (e.g., yourcompany.atlassian.net)',
},
pageId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Confluence page ID to add the label to',
},
labelName: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Name of the label to add',
},
prefix: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Label prefix: global (default), my, team, or system',
},
cloudId: {
type: 'string',
required: false,
visibility: 'user-only',
description:
'Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain.',
},
},
request: {
url: () => '/api/tools/confluence/labels',
method: 'POST',
headers: (params: ConfluenceAddLabelParams) => ({
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${params.accessToken}`,
}),
body: (params: ConfluenceAddLabelParams) => ({
domain: params.domain,
accessToken: params.accessToken,
pageId: params.pageId?.trim(),
labelName: params.labelName?.trim(),
prefix: params.prefix || 'global',
cloudId: params.cloudId,
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
return {
success: true,
output: {
ts: new Date().toISOString(),
pageId: data.pageId ?? '',
labelName: data.labelName ?? data.name ?? '',
labelId: data.id ?? '',
},
}
},
outputs: {
ts: TIMESTAMP_OUTPUT,
pageId: {
type: 'string',
description: 'Page ID that the label was added to',
},
labelName: {
type: 'string',
description: 'Name of the added label',
},
labelId: {
type: 'string',
description: 'ID of the added label',
},
},
}

View File

@@ -0,0 +1,151 @@
import {
CONTENT_BODY_OUTPUT_PROPERTIES,
TIMESTAMP_OUTPUT,
VERSION_OUTPUT_PROPERTIES,
} from '@/tools/confluence/types'
import type { ToolConfig } from '@/tools/types'
export interface ConfluenceCreateBlogPostParams {
accessToken: string
domain: string
spaceId: string
title: string
content: string
status?: string
cloudId?: string
}
export interface ConfluenceCreateBlogPostResponse {
success: boolean
output: {
ts: string
id: string
title: string
status: string | null
spaceId: string
authorId: string | null
body: Record<string, any> | null
version: Record<string, any> | null
webUrl: string | null
}
}
export const confluenceCreateBlogPostTool: ToolConfig<
ConfluenceCreateBlogPostParams,
ConfluenceCreateBlogPostResponse
> = {
id: 'confluence_create_blogpost',
name: 'Confluence Create Blog Post',
description: 'Create a new blog post in a Confluence space.',
version: '1.0.0',
oauth: {
required: true,
provider: 'confluence',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'OAuth access token for Confluence',
},
domain: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Your Confluence domain (e.g., yourcompany.atlassian.net)',
},
spaceId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The ID of the space to create the blog post in',
},
title: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Title of the blog post',
},
content: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Blog post content in Confluence storage format (HTML)',
},
status: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Blog post status: current (default) or draft',
},
cloudId: {
type: 'string',
required: false,
visibility: 'user-only',
description:
'Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain.',
},
},
request: {
url: () => '/api/tools/confluence/blogposts',
method: 'POST',
headers: (params: ConfluenceCreateBlogPostParams) => ({
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${params.accessToken}`,
}),
body: (params: ConfluenceCreateBlogPostParams) => ({
domain: params.domain,
accessToken: params.accessToken,
spaceId: params.spaceId?.trim(),
title: params.title,
content: params.content,
status: params.status || 'current',
cloudId: params.cloudId,
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
return {
success: true,
output: {
ts: new Date().toISOString(),
id: data.id ?? '',
title: data.title ?? '',
status: data.status ?? null,
spaceId: data.spaceId ?? '',
authorId: data.authorId ?? null,
body: data.body ?? null,
version: data.version ?? null,
webUrl: data.webUrl ?? data._links?.webui ?? null,
},
}
},
outputs: {
ts: TIMESTAMP_OUTPUT,
id: { type: 'string', description: 'Created blog post ID' },
title: { type: 'string', description: 'Blog post title' },
status: { type: 'string', description: 'Blog post status', optional: true },
spaceId: { type: 'string', description: 'Space ID' },
authorId: { type: 'string', description: 'Author account ID', optional: true },
body: {
type: 'object',
description: 'Blog post body content',
properties: CONTENT_BODY_OUTPUT_PROPERTIES,
optional: true,
},
version: {
type: 'object',
description: 'Blog post version information',
properties: VERSION_OUTPUT_PROPERTIES,
optional: true,
},
webUrl: { type: 'string', description: 'URL to view the blog post', optional: true },
},
}

View File

@@ -1,3 +1,4 @@
import { CONTENT_BODY_OUTPUT_PROPERTIES, VERSION_OUTPUT_PROPERTIES } from '@/tools/confluence/types'
import type { ToolConfig } from '@/tools/types'
export interface ConfluenceCreatePageParams {
@@ -16,6 +17,11 @@ export interface ConfluenceCreatePageResponse {
ts: string
pageId: string
title: string
status: string | null
spaceId: string | null
parentId: string | null
body: Record<string, any> | null
version: Record<string, any> | null
url: string
}
}
@@ -109,8 +115,13 @@ export const confluenceCreatePageTool: ToolConfig<
success: true,
output: {
ts: new Date().toISOString(),
pageId: data.id,
title: data.title,
pageId: data.id ?? '',
title: data.title ?? '',
status: data.status ?? null,
spaceId: data.spaceId ?? null,
parentId: data.parentId ?? null,
body: data.body ?? null,
version: data.version ?? null,
url: data.url || data._links?.webui || '',
},
}
@@ -120,6 +131,21 @@ export const confluenceCreatePageTool: ToolConfig<
ts: { type: 'string', description: 'Timestamp of creation' },
pageId: { type: 'string', description: 'Created page ID' },
title: { type: 'string', description: 'Page title' },
status: { type: 'string', description: 'Page status', optional: true },
spaceId: { type: 'string', description: 'Space ID', optional: true },
parentId: { type: 'string', description: 'Parent page ID', optional: true },
body: {
type: 'object',
description: 'Page body content',
properties: CONTENT_BODY_OUTPUT_PROPERTIES,
optional: true,
},
version: {
type: 'object',
description: 'Page version information',
properties: VERSION_OUTPUT_PROPERTIES,
optional: true,
},
url: { type: 'string', description: 'Page URL' },
},
}

View File

@@ -0,0 +1,127 @@
import { TIMESTAMP_OUTPUT, VERSION_OUTPUT_PROPERTIES } from '@/tools/confluence/types'
import type { ToolConfig } from '@/tools/types'
export interface ConfluenceCreatePagePropertyParams {
accessToken: string
domain: string
pageId: string
key: string
value: any
cloudId?: string
}
export interface ConfluenceCreatePagePropertyResponse {
success: boolean
output: {
ts: string
pageId: string
propertyId: string
key: string
value: any
version: {
number: number
} | null
}
}
export const confluenceCreatePagePropertyTool: ToolConfig<
ConfluenceCreatePagePropertyParams,
ConfluenceCreatePagePropertyResponse
> = {
id: 'confluence_create_page_property',
name: 'Confluence Create Page Property',
description: 'Create a new custom property (metadata) on a Confluence page.',
version: '1.0.0',
oauth: {
required: true,
provider: 'confluence',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'OAuth access token for Confluence',
},
domain: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Your Confluence domain (e.g., yourcompany.atlassian.net)',
},
pageId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The ID of the page to add the property to',
},
key: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The key/name for the property',
},
value: {
type: 'json',
required: true,
visibility: 'user-or-llm',
description: 'The value for the property (can be any JSON value)',
},
cloudId: {
type: 'string',
required: false,
visibility: 'user-only',
description:
'Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain.',
},
},
request: {
url: () => '/api/tools/confluence/page-properties',
method: 'POST',
headers: (params: ConfluenceCreatePagePropertyParams) => ({
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${params.accessToken}`,
}),
body: (params: ConfluenceCreatePagePropertyParams) => ({
domain: params.domain,
accessToken: params.accessToken,
pageId: params.pageId?.trim(),
key: params.key,
value: params.value,
cloudId: params.cloudId,
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
return {
success: true,
output: {
ts: new Date().toISOString(),
pageId: data.pageId ?? '',
propertyId: data.id ?? '',
key: data.key ?? '',
value: data.value ?? null,
version: data.version ?? null,
},
}
},
outputs: {
ts: TIMESTAMP_OUTPUT,
pageId: { type: 'string', description: 'ID of the page' },
propertyId: { type: 'string', description: 'ID of the created property' },
key: { type: 'string', description: 'Property key' },
value: { type: 'json', description: 'Property value' },
version: {
type: 'object',
description: 'Version information',
properties: VERSION_OUTPUT_PROPERTIES,
optional: true,
},
},
}

View File

@@ -4,6 +4,7 @@ export interface ConfluenceDeletePageParams {
accessToken: string
domain: string
pageId: string
purge?: boolean
cloudId?: string
}
@@ -22,7 +23,8 @@ export const confluenceDeletePageTool: ToolConfig<
> = {
id: 'confluence_delete_page',
name: 'Confluence Delete Page',
description: 'Delete a Confluence page (moves it to trash where it can be restored).',
description:
'Delete a Confluence page. By default moves to trash; use purge=true to permanently delete.',
version: '1.0.0',
oauth: {
@@ -49,6 +51,13 @@ export const confluenceDeletePageTool: ToolConfig<
visibility: 'user-or-llm',
description: 'Confluence page ID to delete',
},
purge: {
type: 'boolean',
required: false,
visibility: 'user-or-llm',
description:
'If true, permanently deletes the page instead of moving to trash (default: false)',
},
cloudId: {
type: 'string',
required: false,
@@ -72,6 +81,7 @@ export const confluenceDeletePageTool: ToolConfig<
domain: params.domain,
accessToken: params.accessToken,
pageId: params.pageId,
purge: params.purge || false,
cloudId: params.cloudId,
}
},

View File

@@ -0,0 +1,144 @@
import {
CONTENT_BODY_OUTPUT_PROPERTIES,
TIMESTAMP_OUTPUT,
VERSION_OUTPUT_PROPERTIES,
} from '@/tools/confluence/types'
import type { ToolConfig } from '@/tools/types'
export interface ConfluenceGetBlogPostParams {
accessToken: string
domain: string
blogPostId: string
bodyFormat?: string
cloudId?: string
}
export interface ConfluenceGetBlogPostResponse {
success: boolean
output: {
ts: string
id: string
title: string
status: string | null
spaceId: string | null
authorId: string | null
createdAt: string | null
version: {
number: number
message?: string
createdAt?: string
} | null
body: {
storage?: { value: string }
} | null
webUrl: string | null
}
}
export const confluenceGetBlogPostTool: ToolConfig<
ConfluenceGetBlogPostParams,
ConfluenceGetBlogPostResponse
> = {
id: 'confluence_get_blogpost',
name: 'Confluence Get Blog Post',
description: 'Get a specific Confluence blog post by ID, including its content.',
version: '1.0.0',
oauth: {
required: true,
provider: 'confluence',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'OAuth access token for Confluence',
},
domain: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Your Confluence domain (e.g., yourcompany.atlassian.net)',
},
blogPostId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The ID of the blog post to retrieve',
},
bodyFormat: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Format for blog post body: storage, atlas_doc_format, or view',
},
cloudId: {
type: 'string',
required: false,
visibility: 'user-only',
description:
'Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain.',
},
},
request: {
url: () => '/api/tools/confluence/blogposts',
method: 'POST',
headers: (params: ConfluenceGetBlogPostParams) => ({
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${params.accessToken}`,
}),
body: (params: ConfluenceGetBlogPostParams) => ({
domain: params.domain,
accessToken: params.accessToken,
blogPostId: params.blogPostId?.trim(),
bodyFormat: params.bodyFormat || 'storage',
cloudId: params.cloudId,
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
return {
success: true,
output: {
ts: new Date().toISOString(),
id: data.id ?? '',
title: data.title ?? '',
status: data.status ?? null,
spaceId: data.spaceId ?? null,
authorId: data.authorId ?? null,
createdAt: data.createdAt ?? null,
version: data.version ?? null,
body: data.body ?? null,
webUrl: data.webUrl ?? null,
},
}
},
outputs: {
ts: TIMESTAMP_OUTPUT,
id: { type: 'string', description: 'Blog post ID' },
title: { type: 'string', description: 'Blog post title' },
status: { type: 'string', description: 'Blog post status', optional: true },
spaceId: { type: 'string', description: 'Space ID', optional: true },
authorId: { type: 'string', description: 'Author account ID', optional: true },
createdAt: { type: 'string', description: 'Creation timestamp', optional: true },
version: {
type: 'object',
description: 'Version information',
properties: VERSION_OUTPUT_PROPERTIES,
optional: true,
},
body: {
type: 'object',
description: 'Blog post body content in requested format(s)',
properties: CONTENT_BODY_OUTPUT_PROPERTIES,
optional: true,
},
webUrl: { type: 'string', description: 'URL to view the blog post', optional: true },
},
}

View File

@@ -0,0 +1,126 @@
import { TIMESTAMP_OUTPUT } from '@/tools/confluence/types'
import type { ToolConfig } from '@/tools/types'
export interface ConfluenceGetPageAncestorsParams {
accessToken: string
domain: string
pageId: string
limit?: number
cloudId?: string
}
export interface ConfluenceGetPageAncestorsResponse {
success: boolean
output: {
ts: string
pageId: string
ancestors: Array<{
id: string
title: string
status: string | null
spaceId: string | null
webUrl: string | null
}>
}
}
export const confluenceGetPageAncestorsTool: ToolConfig<
ConfluenceGetPageAncestorsParams,
ConfluenceGetPageAncestorsResponse
> = {
id: 'confluence_get_page_ancestors',
name: 'Confluence Get Page Ancestors',
description:
'Get the ancestor (parent) pages of a specific Confluence page. Returns the full hierarchy from the page up to the root.',
version: '1.0.0',
oauth: {
required: true,
provider: 'confluence',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'OAuth access token for Confluence',
},
domain: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Your Confluence domain (e.g., yourcompany.atlassian.net)',
},
pageId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The ID of the page to get ancestors for',
},
limit: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Maximum number of ancestors to return (default: 25, max: 250)',
},
cloudId: {
type: 'string',
required: false,
visibility: 'user-only',
description:
'Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain.',
},
},
request: {
url: () => '/api/tools/confluence/page-ancestors',
method: 'POST',
headers: (params: ConfluenceGetPageAncestorsParams) => ({
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${params.accessToken}`,
}),
body: (params: ConfluenceGetPageAncestorsParams) => ({
domain: params.domain,
accessToken: params.accessToken,
pageId: params.pageId?.trim(),
limit: params.limit ? Number(params.limit) : 25,
cloudId: params.cloudId,
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
return {
success: true,
output: {
ts: new Date().toISOString(),
pageId: data.pageId ?? '',
ancestors: data.ancestors ?? [],
},
}
},
outputs: {
ts: TIMESTAMP_OUTPUT,
pageId: {
type: 'string',
description: 'ID of the page whose ancestors were retrieved',
},
ancestors: {
type: 'array',
description: 'Array of ancestor pages, ordered from direct parent to root',
items: {
type: 'object',
properties: {
id: { type: 'string', description: 'Ancestor page ID' },
title: { type: 'string', description: 'Ancestor page title' },
status: { type: 'string', description: 'Page status', optional: true },
spaceId: { type: 'string', description: 'Space ID', optional: true },
webUrl: { type: 'string', description: 'URL to view the page', optional: true },
},
},
},
},
}

View File

@@ -0,0 +1,143 @@
import { TIMESTAMP_OUTPUT } from '@/tools/confluence/types'
import type { ToolConfig } from '@/tools/types'
export interface ConfluenceGetPageChildrenParams {
accessToken: string
domain: string
pageId: string
limit?: number
cursor?: string
cloudId?: string
}
export interface ConfluenceGetPageChildrenResponse {
success: boolean
output: {
ts: string
parentId: string
children: Array<{
id: string
title: string
status: string | null
spaceId: string | null
childPosition: number | null
webUrl: string | null
}>
nextCursor: string | null
}
}
export const confluenceGetPageChildrenTool: ToolConfig<
ConfluenceGetPageChildrenParams,
ConfluenceGetPageChildrenResponse
> = {
id: 'confluence_get_page_children',
name: 'Confluence Get Page Children',
description:
'Get all child pages of a specific Confluence page. Useful for navigating page hierarchies.',
version: '1.0.0',
oauth: {
required: true,
provider: 'confluence',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'OAuth access token for Confluence',
},
domain: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Your Confluence domain (e.g., yourcompany.atlassian.net)',
},
pageId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The ID of the parent page to get children from',
},
limit: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Maximum number of child pages to return (default: 50, max: 250)',
},
cursor: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Pagination cursor from previous response to get the next page of results',
},
cloudId: {
type: 'string',
required: false,
visibility: 'user-only',
description:
'Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain.',
},
},
request: {
url: () => '/api/tools/confluence/page-children',
method: 'POST',
headers: (params: ConfluenceGetPageChildrenParams) => ({
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${params.accessToken}`,
}),
body: (params: ConfluenceGetPageChildrenParams) => ({
domain: params.domain,
accessToken: params.accessToken,
pageId: params.pageId?.trim(),
limit: params.limit ? Number(params.limit) : 50,
cursor: params.cursor,
cloudId: params.cloudId,
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
return {
success: true,
output: {
ts: new Date().toISOString(),
parentId: data.parentId ?? '',
children: data.children ?? [],
nextCursor: data.nextCursor ?? null,
},
}
},
outputs: {
ts: TIMESTAMP_OUTPUT,
parentId: {
type: 'string',
description: 'ID of the parent page',
},
children: {
type: 'array',
description: 'Array of child pages',
items: {
type: 'object',
properties: {
id: { type: 'string', description: 'Child page ID' },
title: { type: 'string', description: 'Child page title' },
status: { type: 'string', description: 'Page status', optional: true },
spaceId: { type: 'string', description: 'Space ID', optional: true },
childPosition: { type: 'number', description: 'Position among siblings', optional: true },
webUrl: { type: 'string', description: 'URL to view the page', optional: true },
},
},
},
nextCursor: {
type: 'string',
description: 'Cursor for fetching the next page of results',
optional: true,
},
},
}

View File

@@ -0,0 +1,123 @@
import { DETAILED_VERSION_OUTPUT_PROPERTIES, TIMESTAMP_OUTPUT } from '@/tools/confluence/types'
import type { ToolConfig } from '@/tools/types'
export interface ConfluenceGetPageVersionParams {
accessToken: string
domain: string
pageId: string
versionNumber: number
cloudId?: string
}
export interface ConfluenceGetPageVersionResponse {
success: boolean
output: {
ts: string
pageId: string
version: {
number: number
message: string | null
minorEdit: boolean
authorId: string | null
createdAt: string | null
contentTypeModified: boolean | null
collaborators: string[] | null
prevVersion: number | null
nextVersion: number | null
}
}
}
export const confluenceGetPageVersionTool: ToolConfig<
ConfluenceGetPageVersionParams,
ConfluenceGetPageVersionResponse
> = {
id: 'confluence_get_page_version',
name: 'Confluence Get Page Version',
description: 'Get details about a specific version of a Confluence page.',
version: '1.0.0',
oauth: {
required: true,
provider: 'confluence',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'OAuth access token for Confluence',
},
domain: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Your Confluence domain (e.g., yourcompany.atlassian.net)',
},
pageId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The ID of the page',
},
versionNumber: {
type: 'number',
required: true,
visibility: 'user-or-llm',
description: 'The version number to retrieve (e.g., 1, 2, 3)',
},
cloudId: {
type: 'string',
required: false,
visibility: 'user-only',
description:
'Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain.',
},
},
request: {
url: () => '/api/tools/confluence/page-versions',
method: 'POST',
headers: (params: ConfluenceGetPageVersionParams) => ({
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${params.accessToken}`,
}),
body: (params: ConfluenceGetPageVersionParams) => ({
domain: params.domain,
accessToken: params.accessToken,
pageId: params.pageId?.trim(),
versionNumber: Number(params.versionNumber),
cloudId: params.cloudId,
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
return {
success: true,
output: {
ts: new Date().toISOString(),
pageId: data.pageId ?? '',
version: data.version ?? {
number: 0,
message: null,
minorEdit: false,
authorId: null,
createdAt: null,
},
},
}
},
outputs: {
ts: TIMESTAMP_OUTPUT,
pageId: { type: 'string', description: 'ID of the page' },
version: {
type: 'object',
description: 'Detailed version information',
properties: DETAILED_VERSION_OUTPUT_PROPERTIES,
},
},
}

View File

@@ -1,3 +1,4 @@
import { SPACE_DESCRIPTION_OUTPUT_PROPERTIES, TIMESTAMP_OUTPUT } from '@/tools/confluence/types'
import type { ToolConfig } from '@/tools/types'
export interface ConfluenceGetSpaceParams {
@@ -17,6 +18,13 @@ export interface ConfluenceGetSpaceResponse {
type: string
status: string
url: string
authorId: string | null
createdAt: string | null
homepageId: string | null
description: {
value: string
representation: string
} | null
}
}
@@ -95,17 +103,34 @@ export const confluenceGetSpaceTool: ToolConfig<
type: data.type,
status: data.status,
url: data._links?.webui || '',
authorId: data.authorId ?? null,
createdAt: data.createdAt ?? null,
homepageId: data.homepageId ?? null,
description: data.description ?? null,
},
}
},
outputs: {
ts: { type: 'string', description: 'Timestamp of retrieval' },
ts: TIMESTAMP_OUTPUT,
spaceId: { type: 'string', description: 'Space ID' },
name: { type: 'string', description: 'Space name' },
key: { type: 'string', description: 'Space key' },
type: { type: 'string', description: 'Space type' },
status: { type: 'string', description: 'Space status' },
url: { type: 'string', description: 'Space URL' },
type: { type: 'string', description: 'Space type (global, personal)' },
status: { type: 'string', description: 'Space status (current, archived)' },
url: { type: 'string', description: 'URL to view the space in Confluence' },
authorId: { type: 'string', description: 'Account ID of the space creator', optional: true },
createdAt: {
type: 'string',
description: 'ISO 8601 timestamp when the space was created',
optional: true,
},
homepageId: { type: 'string', description: 'ID of the space homepage', optional: true },
description: {
type: 'object',
description: 'Space description content',
properties: SPACE_DESCRIPTION_OUTPUT_PROPERTIES,
optional: true,
},
},
}

View File

@@ -1,24 +1,42 @@
import { confluenceAddLabelTool } from '@/tools/confluence/add_label'
import { confluenceCreateBlogPostTool } from '@/tools/confluence/create_blogpost'
import { confluenceCreateCommentTool } from '@/tools/confluence/create_comment'
import { confluenceCreatePageTool } from '@/tools/confluence/create_page'
import { confluenceCreatePagePropertyTool } from '@/tools/confluence/create_page_property'
import { confluenceDeleteAttachmentTool } from '@/tools/confluence/delete_attachment'
import { confluenceDeleteCommentTool } from '@/tools/confluence/delete_comment'
import { confluenceDeletePageTool } from '@/tools/confluence/delete_page'
import { confluenceGetBlogPostTool } from '@/tools/confluence/get_blogpost'
import { confluenceGetPageAncestorsTool } from '@/tools/confluence/get_page_ancestors'
import { confluenceGetPageChildrenTool } from '@/tools/confluence/get_page_children'
import { confluenceGetPageVersionTool } from '@/tools/confluence/get_page_version'
import { confluenceGetSpaceTool } from '@/tools/confluence/get_space'
import { confluenceListAttachmentsTool } from '@/tools/confluence/list_attachments'
import { confluenceListBlogPostsTool } from '@/tools/confluence/list_blogposts'
import { confluenceListBlogPostsInSpaceTool } from '@/tools/confluence/list_blogposts_in_space'
import { confluenceListCommentsTool } from '@/tools/confluence/list_comments'
import { confluenceListLabelsTool } from '@/tools/confluence/list_labels'
import { confluenceListPagePropertiesTool } from '@/tools/confluence/list_page_properties'
import { confluenceListPageVersionsTool } from '@/tools/confluence/list_page_versions'
import { confluenceListPagesInSpaceTool } from '@/tools/confluence/list_pages_in_space'
import { confluenceListSpacesTool } from '@/tools/confluence/list_spaces'
import { confluenceRetrieveTool } from '@/tools/confluence/retrieve'
import { confluenceSearchTool } from '@/tools/confluence/search'
import { confluenceSearchInSpaceTool } from '@/tools/confluence/search_in_space'
import {
ATTACHMENT_ITEM_PROPERTIES,
ATTACHMENT_OUTPUT,
ATTACHMENTS_OUTPUT,
BODY_FORMAT_PROPERTIES,
COMMENT_BODY_OUTPUT_PROPERTIES,
COMMENT_ITEM_PROPERTIES,
COMMENT_OUTPUT,
COMMENTS_OUTPUT,
CONTENT_BODY_OUTPUT,
CONTENT_BODY_OUTPUT_PROPERTIES,
DELETED_OUTPUT,
DETAILED_VERSION_OUTPUT,
DETAILED_VERSION_OUTPUT_PROPERTIES,
LABEL_ITEM_PROPERTIES,
LABEL_OUTPUT,
LABELS_OUTPUT,
@@ -46,20 +64,41 @@ import { confluenceUpdateCommentTool } from '@/tools/confluence/update_comment'
import { confluenceUploadAttachmentTool } from '@/tools/confluence/upload_attachment'
export {
// Tools
// Page Tools
confluenceRetrieveTool,
confluenceUpdateTool,
confluenceCreatePageTool,
confluenceDeletePageTool,
confluenceListPagesInSpaceTool,
confluenceGetPageChildrenTool,
confluenceGetPageAncestorsTool,
// Page Version Tools
confluenceListPageVersionsTool,
confluenceGetPageVersionTool,
// Page Properties Tools
confluenceListPagePropertiesTool,
confluenceCreatePagePropertyTool,
// Blog Post Tools
confluenceListBlogPostsTool,
confluenceGetBlogPostTool,
confluenceCreateBlogPostTool,
confluenceListBlogPostsInSpaceTool,
// Search Tools
confluenceSearchTool,
confluenceSearchInSpaceTool,
// Comment Tools
confluenceCreateCommentTool,
confluenceListCommentsTool,
confluenceUpdateCommentTool,
confluenceDeleteCommentTool,
// Attachment Tools
confluenceListAttachmentsTool,
confluenceDeleteAttachmentTool,
confluenceUploadAttachmentTool,
// Label Tools
confluenceListLabelsTool,
confluenceAddLabelTool,
// Space Tools
confluenceGetSpaceTool,
confluenceListSpacesTool,
// Item property constants (for use in outputs)
@@ -70,7 +109,10 @@ export {
SEARCH_RESULT_ITEM_PROPERTIES,
SPACE_ITEM_PROPERTIES,
VERSION_OUTPUT_PROPERTIES,
DETAILED_VERSION_OUTPUT_PROPERTIES,
COMMENT_BODY_OUTPUT_PROPERTIES,
CONTENT_BODY_OUTPUT_PROPERTIES,
BODY_FORMAT_PROPERTIES,
SPACE_DESCRIPTION_OUTPUT_PROPERTIES,
SEARCH_RESULT_SPACE_PROPERTIES,
PAGINATION_LINKS_PROPERTIES,
@@ -79,6 +121,8 @@ export {
ATTACHMENTS_OUTPUT,
COMMENT_OUTPUT,
COMMENTS_OUTPUT,
CONTENT_BODY_OUTPUT,
DETAILED_VERSION_OUTPUT,
LABEL_OUTPUT,
LABELS_OUTPUT,
PAGE_OUTPUT,

View File

@@ -6,6 +6,7 @@ export interface ConfluenceListAttachmentsParams {
domain: string
pageId: string
limit?: number
cursor?: string
cloudId?: string
}
@@ -20,6 +21,7 @@ export interface ConfluenceListAttachmentsResponse {
mediaType: string
downloadUrl: string
}>
nextCursor: string | null
}
}
@@ -60,7 +62,13 @@ export const confluenceListAttachmentsTool: ToolConfig<
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Maximum number of attachments to return (default: 25)',
description: 'Maximum number of attachments to return (default: 50, max: 250)',
},
cursor: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Pagination cursor from previous response',
},
cloudId: {
type: 'string',
@@ -77,8 +85,11 @@ export const confluenceListAttachmentsTool: ToolConfig<
domain: params.domain,
accessToken: params.accessToken,
pageId: params.pageId,
limit: String(params.limit || 25),
limit: String(params.limit || 50),
})
if (params.cursor) {
query.set('cursor', params.cursor)
}
if (params.cloudId) {
query.set('cloudId', params.cloudId)
}
@@ -91,15 +102,6 @@ export const confluenceListAttachmentsTool: ToolConfig<
Authorization: `Bearer ${params.accessToken}`,
}
},
body: (params: ConfluenceListAttachmentsParams) => {
return {
domain: params.domain,
accessToken: params.accessToken,
cloudId: params.cloudId,
pageId: params.pageId,
limit: params.limit ? Number(params.limit) : 25,
}
},
},
transformResponse: async (response: Response) => {
@@ -109,6 +111,7 @@ export const confluenceListAttachmentsTool: ToolConfig<
output: {
ts: new Date().toISOString(),
attachments: data.attachments || [],
nextCursor: data.nextCursor ?? null,
},
}
},
@@ -116,5 +119,10 @@ export const confluenceListAttachmentsTool: ToolConfig<
outputs: {
ts: TIMESTAMP_OUTPUT,
attachments: ATTACHMENTS_OUTPUT,
nextCursor: {
type: 'string',
description: 'Cursor for fetching the next page of results',
optional: true,
},
},
}

View File

@@ -0,0 +1,167 @@
import { TIMESTAMP_OUTPUT, VERSION_OUTPUT_PROPERTIES } from '@/tools/confluence/types'
import type { ToolConfig } from '@/tools/types'
export interface ConfluenceListBlogPostsParams {
accessToken: string
domain: string
limit?: number
status?: string
sort?: string
cursor?: string
cloudId?: string
}
export interface ConfluenceListBlogPostsResponse {
success: boolean
output: {
ts: string
blogPosts: Array<{
id: string
title: string
status: string | null
spaceId: string | null
authorId: string | null
createdAt: string | null
version: {
number: number
message?: string
createdAt?: string
} | null
webUrl: string | null
}>
nextCursor: string | null
}
}
export const confluenceListBlogPostsTool: ToolConfig<
ConfluenceListBlogPostsParams,
ConfluenceListBlogPostsResponse
> = {
id: 'confluence_list_blogposts',
name: 'Confluence List Blog Posts',
description: 'List all blog posts across all accessible Confluence spaces.',
version: '1.0.0',
oauth: {
required: true,
provider: 'confluence',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'OAuth access token for Confluence',
},
domain: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Your Confluence domain (e.g., yourcompany.atlassian.net)',
},
limit: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Maximum number of blog posts to return (default: 25, max: 250)',
},
status: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Filter by status: current, archived, trashed, or draft',
},
sort: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'Sort order: created-date, -created-date, modified-date, -modified-date, title, -title',
},
cursor: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Pagination cursor from previous response',
},
cloudId: {
type: 'string',
required: false,
visibility: 'user-only',
description:
'Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain.',
},
},
request: {
url: (params: ConfluenceListBlogPostsParams) => {
const query = new URLSearchParams({
domain: params.domain,
accessToken: params.accessToken,
limit: String(params.limit || 25),
})
if (params.status) {
query.set('status', params.status)
}
if (params.sort) {
query.set('sort', params.sort)
}
if (params.cursor) {
query.set('cursor', params.cursor)
}
if (params.cloudId) {
query.set('cloudId', params.cloudId)
}
return `/api/tools/confluence/blogposts?${query.toString()}`
},
method: 'GET',
headers: (params: ConfluenceListBlogPostsParams) => ({
Accept: 'application/json',
Authorization: `Bearer ${params.accessToken}`,
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
return {
success: true,
output: {
ts: new Date().toISOString(),
blogPosts: data.blogPosts ?? [],
nextCursor: data.nextCursor ?? null,
},
}
},
outputs: {
ts: TIMESTAMP_OUTPUT,
blogPosts: {
type: 'array',
description: 'Array of blog posts',
items: {
type: 'object',
properties: {
id: { type: 'string', description: 'Blog post ID' },
title: { type: 'string', description: 'Blog post title' },
status: { type: 'string', description: 'Blog post status', optional: true },
spaceId: { type: 'string', description: 'Space ID', optional: true },
authorId: { type: 'string', description: 'Author account ID', optional: true },
createdAt: { type: 'string', description: 'Creation timestamp', optional: true },
version: {
type: 'object',
description: 'Version information',
properties: VERSION_OUTPUT_PROPERTIES,
optional: true,
},
webUrl: { type: 'string', description: 'URL to view the blog post', optional: true },
},
},
},
nextCursor: {
type: 'string',
description: 'Cursor for fetching the next page of results',
optional: true,
},
},
}

View File

@@ -0,0 +1,178 @@
import {
CONTENT_BODY_OUTPUT_PROPERTIES,
TIMESTAMP_OUTPUT,
VERSION_OUTPUT_PROPERTIES,
} from '@/tools/confluence/types'
import type { ToolConfig } from '@/tools/types'
export interface ConfluenceListBlogPostsInSpaceParams {
accessToken: string
domain: string
spaceId: string
limit?: number
status?: string
bodyFormat?: string
cursor?: string
cloudId?: string
}
export interface ConfluenceListBlogPostsInSpaceResponse {
success: boolean
output: {
ts: string
blogPosts: Array<{
id: string
title: string
status: string | null
spaceId: string | null
authorId: string | null
createdAt: string | null
version: {
number: number
message?: string
createdAt?: string
} | null
body: {
storage?: { value: string }
} | null
webUrl: string | null
}>
nextCursor: string | null
}
}
export const confluenceListBlogPostsInSpaceTool: ToolConfig<
ConfluenceListBlogPostsInSpaceParams,
ConfluenceListBlogPostsInSpaceResponse
> = {
id: 'confluence_list_blogposts_in_space',
name: 'Confluence List Blog Posts in Space',
description: 'List all blog posts within a specific Confluence space.',
version: '1.0.0',
oauth: {
required: true,
provider: 'confluence',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'OAuth access token for Confluence',
},
domain: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Your Confluence domain (e.g., yourcompany.atlassian.net)',
},
spaceId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The ID of the Confluence space to list blog posts from',
},
limit: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Maximum number of blog posts to return (default: 25, max: 250)',
},
status: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Filter by status: current, archived, trashed, or draft',
},
bodyFormat: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Format for blog post body: storage, atlas_doc_format, or view',
},
cursor: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Pagination cursor from previous response',
},
cloudId: {
type: 'string',
required: false,
visibility: 'user-only',
description:
'Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain.',
},
},
request: {
url: () => '/api/tools/confluence/space-blogposts',
method: 'POST',
headers: (params: ConfluenceListBlogPostsInSpaceParams) => ({
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${params.accessToken}`,
}),
body: (params: ConfluenceListBlogPostsInSpaceParams) => ({
domain: params.domain,
accessToken: params.accessToken,
spaceId: params.spaceId?.trim(),
limit: params.limit ? Number(params.limit) : 25,
status: params.status,
bodyFormat: params.bodyFormat,
cursor: params.cursor,
cloudId: params.cloudId,
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
return {
success: true,
output: {
ts: new Date().toISOString(),
blogPosts: data.blogPosts ?? [],
nextCursor: data.nextCursor ?? null,
},
}
},
outputs: {
ts: TIMESTAMP_OUTPUT,
blogPosts: {
type: 'array',
description: 'Array of blog posts in the space',
items: {
type: 'object',
properties: {
id: { type: 'string', description: 'Blog post ID' },
title: { type: 'string', description: 'Blog post title' },
status: { type: 'string', description: 'Blog post status', optional: true },
spaceId: { type: 'string', description: 'Space ID', optional: true },
authorId: { type: 'string', description: 'Author account ID', optional: true },
createdAt: { type: 'string', description: 'Creation timestamp', optional: true },
version: {
type: 'object',
description: 'Version information',
properties: VERSION_OUTPUT_PROPERTIES,
optional: true,
},
body: {
type: 'object',
description: 'Blog post body content',
properties: CONTENT_BODY_OUTPUT_PROPERTIES,
optional: true,
},
webUrl: { type: 'string', description: 'URL to view the blog post', optional: true },
},
},
},
nextCursor: {
type: 'string',
description: 'Cursor for fetching the next page of results',
optional: true,
},
},
}

View File

@@ -6,6 +6,8 @@ export interface ConfluenceListCommentsParams {
domain: string
pageId: string
limit?: number
bodyFormat?: string
cursor?: string
cloudId?: string
}
@@ -19,6 +21,7 @@ export interface ConfluenceListCommentsResponse {
createdAt: string
authorId: string
}>
nextCursor: string | null
}
}
@@ -61,6 +64,19 @@ export const confluenceListCommentsTool: ToolConfig<
visibility: 'user-or-llm',
description: 'Maximum number of comments to return (default: 25)',
},
bodyFormat: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'Format for the comment body: storage, atlas_doc_format, view, or export_view (default: storage)',
},
cursor: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Pagination cursor from previous response',
},
cloudId: {
type: 'string',
required: false,
@@ -78,6 +94,12 @@ export const confluenceListCommentsTool: ToolConfig<
pageId: params.pageId,
limit: String(params.limit || 25),
})
if (params.bodyFormat) {
query.set('bodyFormat', params.bodyFormat)
}
if (params.cursor) {
query.set('cursor', params.cursor)
}
if (params.cloudId) {
query.set('cloudId', params.cloudId)
}
@@ -90,15 +112,6 @@ export const confluenceListCommentsTool: ToolConfig<
Authorization: `Bearer ${params.accessToken}`,
}
},
body: (params: ConfluenceListCommentsParams) => {
return {
domain: params.domain,
accessToken: params.accessToken,
cloudId: params.cloudId,
pageId: params.pageId,
limit: params.limit ? Number(params.limit) : 25,
}
},
},
transformResponse: async (response: Response) => {
@@ -108,6 +121,7 @@ export const confluenceListCommentsTool: ToolConfig<
output: {
ts: new Date().toISOString(),
comments: data.comments || [],
nextCursor: data.nextCursor ?? null,
},
}
},
@@ -115,5 +129,10 @@ export const confluenceListCommentsTool: ToolConfig<
outputs: {
ts: TIMESTAMP_OUTPUT,
comments: COMMENTS_OUTPUT,
nextCursor: {
type: 'string',
description: 'Cursor for fetching the next page of results',
optional: true,
},
},
}

View File

@@ -5,6 +5,8 @@ export interface ConfluenceListLabelsParams {
accessToken: string
domain: string
pageId: string
limit?: number
cursor?: string
cloudId?: string
}
@@ -17,6 +19,7 @@ export interface ConfluenceListLabelsResponse {
name: string
prefix: string
}>
nextCursor: string | null
}
}
@@ -53,6 +56,18 @@ export const confluenceListLabelsTool: ToolConfig<
visibility: 'user-or-llm',
description: 'Confluence page ID to list labels from',
},
limit: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Maximum number of labels to return (default: 25, max: 250)',
},
cursor: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Pagination cursor from previous response',
},
cloudId: {
type: 'string',
required: false,
@@ -68,7 +83,11 @@ export const confluenceListLabelsTool: ToolConfig<
domain: params.domain,
accessToken: params.accessToken,
pageId: params.pageId,
limit: String(params.limit || 25),
})
if (params.cursor) {
query.set('cursor', params.cursor)
}
if (params.cloudId) {
query.set('cloudId', params.cloudId)
}
@@ -90,6 +109,7 @@ export const confluenceListLabelsTool: ToolConfig<
output: {
ts: new Date().toISOString(),
labels: data.labels || [],
nextCursor: data.nextCursor ?? null,
},
}
},
@@ -104,5 +124,10 @@ export const confluenceListLabelsTool: ToolConfig<
properties: LABEL_ITEM_PROPERTIES,
},
},
nextCursor: {
type: 'string',
description: 'Cursor for fetching the next page of results',
optional: true,
},
},
}

View File

@@ -0,0 +1,149 @@
import { TIMESTAMP_OUTPUT, VERSION_OUTPUT_PROPERTIES } from '@/tools/confluence/types'
import type { ToolConfig } from '@/tools/types'
export interface ConfluenceListPagePropertiesParams {
accessToken: string
domain: string
pageId: string
limit?: number
cursor?: string
cloudId?: string
}
export interface ConfluenceListPagePropertiesResponse {
success: boolean
output: {
ts: string
pageId: string
properties: Array<{
id: string
key: string
value: any
version: {
number: number
message?: string
createdAt?: string
} | null
}>
nextCursor: string | null
}
}
export const confluenceListPagePropertiesTool: ToolConfig<
ConfluenceListPagePropertiesParams,
ConfluenceListPagePropertiesResponse
> = {
id: 'confluence_list_page_properties',
name: 'Confluence List Page Properties',
description: 'List all custom properties (metadata) attached to a Confluence page.',
version: '1.0.0',
oauth: {
required: true,
provider: 'confluence',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'OAuth access token for Confluence',
},
domain: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Your Confluence domain (e.g., yourcompany.atlassian.net)',
},
pageId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The ID of the page to list properties from',
},
limit: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Maximum number of properties to return (default: 50, max: 250)',
},
cursor: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Pagination cursor from previous response',
},
cloudId: {
type: 'string',
required: false,
visibility: 'user-only',
description:
'Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain.',
},
},
request: {
url: (params: ConfluenceListPagePropertiesParams) => {
const query = new URLSearchParams({
domain: params.domain,
accessToken: params.accessToken,
pageId: params.pageId,
limit: String(params.limit || 50),
})
if (params.cursor) {
query.set('cursor', params.cursor)
}
if (params.cloudId) {
query.set('cloudId', params.cloudId)
}
return `/api/tools/confluence/page-properties?${query.toString()}`
},
method: 'GET',
headers: (params: ConfluenceListPagePropertiesParams) => ({
Accept: 'application/json',
Authorization: `Bearer ${params.accessToken}`,
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
return {
success: true,
output: {
ts: new Date().toISOString(),
pageId: data.pageId ?? '',
properties: data.properties ?? [],
nextCursor: data.nextCursor ?? null,
},
}
},
outputs: {
ts: TIMESTAMP_OUTPUT,
pageId: { type: 'string', description: 'ID of the page' },
properties: {
type: 'array',
description: 'Array of content properties',
items: {
type: 'object',
properties: {
id: { type: 'string', description: 'Property ID' },
key: { type: 'string', description: 'Property key' },
value: { type: 'json', description: 'Property value (can be any JSON)' },
version: {
type: 'object',
description: 'Version information',
properties: VERSION_OUTPUT_PROPERTIES,
optional: true,
},
},
},
},
nextCursor: {
type: 'string',
description: 'Cursor for fetching the next page of results',
optional: true,
},
},
}

View File

@@ -0,0 +1,131 @@
import { TIMESTAMP_OUTPUT, VERSION_OUTPUT_PROPERTIES } from '@/tools/confluence/types'
import type { ToolConfig } from '@/tools/types'
export interface ConfluenceListPageVersionsParams {
accessToken: string
domain: string
pageId: string
limit?: number
cursor?: string
cloudId?: string
}
export interface ConfluenceListPageVersionsResponse {
success: boolean
output: {
ts: string
pageId: string
versions: Array<{
number: number
message: string | null
minorEdit: boolean
authorId: string | null
createdAt: string | null
}>
nextCursor: string | null
}
}
export const confluenceListPageVersionsTool: ToolConfig<
ConfluenceListPageVersionsParams,
ConfluenceListPageVersionsResponse
> = {
id: 'confluence_list_page_versions',
name: 'Confluence List Page Versions',
description: 'List all versions (revision history) of a Confluence page.',
version: '1.0.0',
oauth: {
required: true,
provider: 'confluence',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'OAuth access token for Confluence',
},
domain: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Your Confluence domain (e.g., yourcompany.atlassian.net)',
},
pageId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The ID of the page to get versions for',
},
limit: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Maximum number of versions to return (default: 50, max: 250)',
},
cursor: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Pagination cursor from previous response',
},
cloudId: {
type: 'string',
required: false,
visibility: 'user-only',
description:
'Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain.',
},
},
request: {
url: () => '/api/tools/confluence/page-versions',
method: 'POST',
headers: (params: ConfluenceListPageVersionsParams) => ({
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${params.accessToken}`,
}),
body: (params: ConfluenceListPageVersionsParams) => ({
domain: params.domain,
accessToken: params.accessToken,
pageId: params.pageId?.trim(),
limit: params.limit ? Number(params.limit) : 50,
cursor: params.cursor,
cloudId: params.cloudId,
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
return {
success: true,
output: {
ts: new Date().toISOString(),
pageId: data.pageId ?? '',
versions: data.versions ?? [],
nextCursor: data.nextCursor ?? null,
},
}
},
outputs: {
ts: TIMESTAMP_OUTPUT,
pageId: { type: 'string', description: 'ID of the page' },
versions: {
type: 'array',
description: 'Array of page versions',
items: {
type: 'object',
properties: VERSION_OUTPUT_PROPERTIES,
},
},
nextCursor: {
type: 'string',
description: 'Cursor for fetching the next page of results',
optional: true,
},
},
}

View File

@@ -0,0 +1,174 @@
import {
CONTENT_BODY_OUTPUT_PROPERTIES,
PAGE_ITEM_PROPERTIES,
TIMESTAMP_OUTPUT,
} from '@/tools/confluence/types'
import type { ToolConfig } from '@/tools/types'
export interface ConfluenceListPagesInSpaceParams {
accessToken: string
domain: string
spaceId: string
limit?: number
status?: string
bodyFormat?: string
cursor?: string
cloudId?: string
}
export interface ConfluenceListPagesInSpaceResponse {
success: boolean
output: {
ts: string
pages: Array<{
id: string
title: string
status: string | null
spaceId: string | null
parentId: string | null
authorId: string | null
createdAt: string | null
version: {
number: number
message?: string
createdAt?: string
} | null
body: {
storage?: { value: string }
} | null
webUrl: string | null
}>
nextCursor: string | null
}
}
export const confluenceListPagesInSpaceTool: ToolConfig<
ConfluenceListPagesInSpaceParams,
ConfluenceListPagesInSpaceResponse
> = {
id: 'confluence_list_pages_in_space',
name: 'Confluence List Pages in Space',
description:
'List all pages within a specific Confluence space. Supports pagination and filtering by status.',
version: '1.0.0',
oauth: {
required: true,
provider: 'confluence',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'OAuth access token for Confluence',
},
domain: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Your Confluence domain (e.g., yourcompany.atlassian.net)',
},
spaceId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The ID of the Confluence space to list pages from',
},
limit: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Maximum number of pages to return (default: 50, max: 250)',
},
status: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Filter pages by status: current, archived, trashed, or draft',
},
bodyFormat: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'Format for page body content: storage, atlas_doc_format, or view. If not specified, body is not included.',
},
cursor: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Pagination cursor from previous response to get the next page of results',
},
cloudId: {
type: 'string',
required: false,
visibility: 'user-only',
description:
'Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain.',
},
},
request: {
url: () => '/api/tools/confluence/space-pages',
method: 'POST',
headers: (params: ConfluenceListPagesInSpaceParams) => ({
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${params.accessToken}`,
}),
body: (params: ConfluenceListPagesInSpaceParams) => ({
domain: params.domain,
accessToken: params.accessToken,
spaceId: params.spaceId?.trim(),
limit: params.limit ? Number(params.limit) : 50,
status: params.status,
bodyFormat: params.bodyFormat,
cursor: params.cursor,
cloudId: params.cloudId,
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
return {
success: true,
output: {
ts: new Date().toISOString(),
pages: data.pages ?? [],
nextCursor: data.nextCursor ?? null,
},
}
},
outputs: {
ts: TIMESTAMP_OUTPUT,
pages: {
type: 'array',
description: 'Array of pages in the space',
items: {
type: 'object',
properties: {
...PAGE_ITEM_PROPERTIES,
body: {
type: 'object',
description: 'Page body content (if bodyFormat was specified)',
properties: CONTENT_BODY_OUTPUT_PROPERTIES,
optional: true,
},
webUrl: {
type: 'string',
description: 'URL to view the page in Confluence',
optional: true,
},
},
},
},
nextCursor: {
type: 'string',
description: 'Cursor for fetching the next page of results',
optional: true,
},
},
}

View File

@@ -5,6 +5,7 @@ export interface ConfluenceListSpacesParams {
accessToken: string
domain: string
limit?: number
cursor?: string
cloudId?: string
}
@@ -19,6 +20,7 @@ export interface ConfluenceListSpacesResponse {
type: string
status: string
}>
nextCursor: string | null
}
}
@@ -53,7 +55,13 @@ export const confluenceListSpacesTool: ToolConfig<
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Maximum number of spaces to return (default: 25)',
description: 'Maximum number of spaces to return (default: 25, max: 250)',
},
cursor: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Pagination cursor from previous response',
},
cloudId: {
type: 'string',
@@ -71,6 +79,9 @@ export const confluenceListSpacesTool: ToolConfig<
accessToken: params.accessToken,
limit: String(params.limit || 25),
})
if (params.cursor) {
query.set('cursor', params.cursor)
}
if (params.cloudId) {
query.set('cloudId', params.cloudId)
}
@@ -83,14 +94,6 @@ export const confluenceListSpacesTool: ToolConfig<
Authorization: `Bearer ${params.accessToken}`,
}
},
body: (params: ConfluenceListSpacesParams) => {
return {
domain: params.domain,
accessToken: params.accessToken,
cloudId: params.cloudId,
limit: params.limit ? Number(params.limit) : 25,
}
},
},
transformResponse: async (response: Response) => {
@@ -100,6 +103,7 @@ export const confluenceListSpacesTool: ToolConfig<
output: {
ts: new Date().toISOString(),
spaces: data.spaces || [],
nextCursor: data.nextCursor ?? null,
},
}
},
@@ -107,5 +111,10 @@ export const confluenceListSpacesTool: ToolConfig<
outputs: {
ts: TIMESTAMP_OUTPUT,
spaces: SPACES_OUTPUT,
nextCursor: {
type: 'string',
description: 'Cursor for fetching the next page of results',
optional: true,
},
},
}

View File

@@ -1,4 +1,9 @@
import type { ConfluenceRetrieveParams, ConfluenceRetrieveResponse } from '@/tools/confluence/types'
import {
BODY_FORMAT_PROPERTIES,
TIMESTAMP_OUTPUT,
VERSION_OUTPUT_PROPERTIES,
} from '@/tools/confluence/types'
import { transformPageData } from '@/tools/confluence/utils'
import type { ToolConfig } from '@/tools/types'
@@ -71,9 +76,42 @@ export const confluenceRetrieveTool: ToolConfig<
},
outputs: {
ts: { type: 'string', description: 'Timestamp of retrieval' },
ts: TIMESTAMP_OUTPUT,
pageId: { type: 'string', description: 'Confluence page ID' },
content: { type: 'string', description: 'Page content with HTML tags stripped' },
title: { type: 'string', description: 'Page title' },
content: { type: 'string', description: 'Page content with HTML tags stripped' },
status: {
type: 'string',
description: 'Page status (current, archived, trashed, draft)',
optional: true,
},
spaceId: { type: 'string', description: 'ID of the space containing the page', optional: true },
parentId: { type: 'string', description: 'ID of the parent page', optional: true },
authorId: { type: 'string', description: 'Account ID of the page author', optional: true },
createdAt: {
type: 'string',
description: 'ISO 8601 timestamp when the page was created',
optional: true,
},
url: { type: 'string', description: 'URL to view the page in Confluence', optional: true },
body: {
type: 'object',
description: 'Raw page body content in storage format',
properties: {
storage: {
type: 'object',
description: 'Body in storage format (Confluence markup)',
properties: BODY_FORMAT_PROPERTIES,
optional: true,
},
},
optional: true,
},
version: {
type: 'object',
description: 'Page version information',
properties: VERSION_OUTPUT_PROPERTIES,
optional: true,
},
},
}

View File

@@ -0,0 +1,144 @@
import { SEARCH_RESULT_ITEM_PROPERTIES, TIMESTAMP_OUTPUT } from '@/tools/confluence/types'
import type { ToolConfig } from '@/tools/types'
export interface ConfluenceSearchInSpaceParams {
accessToken: string
domain: string
spaceKey: string
query?: string
contentType?: string
limit?: number
cloudId?: string
}
export interface ConfluenceSearchInSpaceResponse {
success: boolean
output: {
ts: string
spaceKey: string
totalSize: number
results: Array<{
id: string
title: string
type: string
status: string | null
url: string
excerpt: string
lastModified: string | null
}>
}
}
export const confluenceSearchInSpaceTool: ToolConfig<
ConfluenceSearchInSpaceParams,
ConfluenceSearchInSpaceResponse
> = {
id: 'confluence_search_in_space',
name: 'Confluence Search in Space',
description:
'Search for content within a specific Confluence space. Optionally filter by text query and content type.',
version: '1.0.0',
oauth: {
required: true,
provider: 'confluence',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'OAuth access token for Confluence',
},
domain: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Your Confluence domain (e.g., yourcompany.atlassian.net)',
},
spaceKey: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The key of the Confluence space to search in (e.g., "ENG", "HR")',
},
query: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Text search query. If not provided, returns all content in the space.',
},
contentType: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Filter by content type: page, blogpost, attachment, or comment',
},
limit: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Maximum number of results to return (default: 25, max: 250)',
},
cloudId: {
type: 'string',
required: false,
visibility: 'user-only',
description:
'Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain.',
},
},
request: {
url: () => '/api/tools/confluence/search-in-space',
method: 'POST',
headers: (params: ConfluenceSearchInSpaceParams) => ({
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${params.accessToken}`,
}),
body: (params: ConfluenceSearchInSpaceParams) => ({
domain: params.domain,
accessToken: params.accessToken,
spaceKey: params.spaceKey?.trim(),
query: params.query,
contentType: params.contentType,
limit: params.limit ? Number(params.limit) : 25,
cloudId: params.cloudId,
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
return {
success: true,
output: {
ts: new Date().toISOString(),
spaceKey: data.spaceKey ?? '',
totalSize: data.totalSize ?? 0,
results: data.results ?? [],
},
}
},
outputs: {
ts: TIMESTAMP_OUTPUT,
spaceKey: {
type: 'string',
description: 'The space key that was searched',
},
totalSize: {
type: 'number',
description: 'Total number of matching results',
},
results: {
type: 'array',
description: 'Array of search results',
items: {
type: 'object',
properties: SEARCH_RESULT_ITEM_PROPERTIES,
},
},
},
}

View File

@@ -26,6 +26,52 @@ export const VERSION_OUTPUT_PROPERTIES = {
},
} as const satisfies Record<string, OutputProperty>
/**
* Detailed version object properties for get_page_version endpoint.
* Based on Confluence API v2 DetailedVersion schema.
*/
export const DETAILED_VERSION_OUTPUT_PROPERTIES = {
number: { type: 'number', description: 'Version number' },
message: { type: 'string', description: 'Version message', optional: true },
minorEdit: { type: 'boolean', description: 'Whether this is a minor edit' },
authorId: { type: 'string', description: 'Account ID of the version author', optional: true },
createdAt: {
type: 'string',
description: 'ISO 8601 timestamp of version creation',
optional: true,
},
contentTypeModified: {
type: 'boolean',
description: 'Whether the content type was modified in this version',
optional: true,
},
collaborators: {
type: 'array',
description: 'List of collaborator account IDs for this version',
items: { type: 'string' },
optional: true,
},
prevVersion: {
type: 'number',
description: 'Previous version number',
optional: true,
},
nextVersion: {
type: 'number',
description: 'Next version number',
optional: true,
},
} as const satisfies Record<string, OutputProperty>
/**
* Complete detailed version object output definition.
*/
export const DETAILED_VERSION_OUTPUT: OutputProperty = {
type: 'object',
description: 'Detailed version information',
properties: DETAILED_VERSION_OUTPUT_PROPERTIES,
}
/**
* Complete version object output definition.
*/
@@ -137,6 +183,54 @@ export const SPACES_OUTPUT: OutputProperty = {
},
}
/**
* Body format inner object properties (storage, view, atlas_doc_format).
* Based on Confluence API v2 body structure.
*/
export const BODY_FORMAT_PROPERTIES = {
value: { type: 'string', description: 'The content value in the specified format' },
representation: {
type: 'string',
description: 'Content representation type',
optional: true,
},
} as const satisfies Record<string, OutputProperty>
/**
* Page/Blog post body object properties.
* Based on Confluence API v2 body structure with multiple format options.
*/
export const CONTENT_BODY_OUTPUT_PROPERTIES = {
storage: {
type: 'object',
description: 'Body in storage format (Confluence markup)',
properties: BODY_FORMAT_PROPERTIES,
optional: true,
},
view: {
type: 'object',
description: 'Body in view format (rendered HTML)',
properties: BODY_FORMAT_PROPERTIES,
optional: true,
},
atlas_doc_format: {
type: 'object',
description: 'Body in Atlassian Document Format (ADF)',
properties: BODY_FORMAT_PROPERTIES,
optional: true,
},
} as const satisfies Record<string, OutputProperty>
/**
* Complete body object output definition for pages and blog posts.
*/
export const CONTENT_BODY_OUTPUT: OutputProperty = {
type: 'object',
description: 'Page or blog post body content in requested format(s)',
properties: CONTENT_BODY_OUTPUT_PROPERTIES,
optional: true,
}
/**
* Comment body object properties.
* Based on Confluence API v2 comment body structure.

View File

@@ -1,4 +1,5 @@
import type { ConfluenceUpdateParams, ConfluenceUpdateResponse } from '@/tools/confluence/types'
import { CONTENT_BODY_OUTPUT_PROPERTIES, VERSION_OUTPUT_PROPERTIES } from '@/tools/confluence/types'
import type { ToolConfig } from '@/tools/types'
export const confluenceUpdateTool: ToolConfig<ConfluenceUpdateParams, ConfluenceUpdateResponse> = {
@@ -98,9 +99,13 @@ export const confluenceUpdateTool: ToolConfig<ConfluenceUpdateParams, Confluence
success: true,
output: {
ts: new Date().toISOString(),
pageId: data.id,
title: data.title,
body: data.body,
pageId: data.id ?? '',
title: data.title ?? '',
status: data.status ?? null,
spaceId: data.spaceId ?? null,
body: data.body ?? null,
version: data.version ?? null,
url: data._links?.webui ?? null,
success: true,
},
}
@@ -110,6 +115,21 @@ export const confluenceUpdateTool: ToolConfig<ConfluenceUpdateParams, Confluence
ts: { type: 'string', description: 'Timestamp of update' },
pageId: { type: 'string', description: 'Confluence page ID' },
title: { type: 'string', description: 'Updated page title' },
status: { type: 'string', description: 'Page status', optional: true },
spaceId: { type: 'string', description: 'Space ID', optional: true },
body: {
type: 'object',
description: 'Page body content in storage format',
properties: CONTENT_BODY_OUTPUT_PROPERTIES,
optional: true,
},
version: {
type: 'object',
description: 'Page version information',
properties: VERSION_OUTPUT_PROPERTIES,
optional: true,
},
url: { type: 'string', description: 'URL to view the page in Confluence', optional: true },
success: { type: 'boolean', description: 'Update operation success status' },
},
}

View File

@@ -57,15 +57,10 @@ function stripHtmlTags(html: string): string {
}
export function transformPageData(data: any) {
const content =
data.body?.view?.value ||
data.body?.storage?.value ||
data.body?.atlas_doc_format?.value ||
data.content ||
data.description ||
`Content for page ${data.title || 'Unknown'}`
const rawContent =
data.body?.storage?.value || data.body?.view?.value || data.body?.atlas_doc_format?.value || ''
let cleanContent = stripHtmlTags(content)
let cleanContent = stripHtmlTags(rawContent)
cleanContent = decodeHtmlEntities(cleanContent)
cleanContent = cleanContent.replace(/\s+/g, ' ').trim()
@@ -73,9 +68,17 @@ export function transformPageData(data: any) {
success: true,
output: {
ts: new Date().toISOString(),
pageId: data.id || '',
pageId: data.id ?? '',
title: data.title ?? '',
content: cleanContent,
title: data.title || '',
status: data.status ?? null,
spaceId: data.spaceId ?? null,
parentId: data.parentId ?? null,
authorId: data.authorId ?? null,
createdAt: data.createdAt ?? null,
url: data._links?.webui ?? null,
body: data.body ?? null,
version: data.version ?? null,
},
}
}

View File

@@ -110,17 +110,30 @@ import {
clerkUpdateUserTool,
} from '@/tools/clerk'
import {
confluenceAddLabelTool,
confluenceCreateBlogPostTool,
confluenceCreateCommentTool,
confluenceCreatePagePropertyTool,
confluenceCreatePageTool,
confluenceDeleteAttachmentTool,
confluenceDeleteCommentTool,
confluenceDeletePageTool,
confluenceGetBlogPostTool,
confluenceGetPageAncestorsTool,
confluenceGetPageChildrenTool,
confluenceGetPageVersionTool,
confluenceGetSpaceTool,
confluenceListAttachmentsTool,
confluenceListBlogPostsInSpaceTool,
confluenceListBlogPostsTool,
confluenceListCommentsTool,
confluenceListLabelsTool,
confluenceListPagePropertiesTool,
confluenceListPagesInSpaceTool,
confluenceListPageVersionsTool,
confluenceListSpacesTool,
confluenceRetrieveTool,
confluenceSearchInSpaceTool,
confluenceSearchTool,
confluenceUpdateCommentTool,
confluenceUpdateTool,
@@ -2608,7 +2621,19 @@ export const tools: Record<string, ToolConfig> = {
confluence_update: confluenceUpdateTool,
confluence_create_page: confluenceCreatePageTool,
confluence_delete_page: confluenceDeletePageTool,
confluence_list_pages_in_space: confluenceListPagesInSpaceTool,
confluence_get_page_children: confluenceGetPageChildrenTool,
confluence_get_page_ancestors: confluenceGetPageAncestorsTool,
confluence_list_page_versions: confluenceListPageVersionsTool,
confluence_get_page_version: confluenceGetPageVersionTool,
confluence_list_page_properties: confluenceListPagePropertiesTool,
confluence_create_page_property: confluenceCreatePagePropertyTool,
confluence_list_blogposts: confluenceListBlogPostsTool,
confluence_get_blogpost: confluenceGetBlogPostTool,
confluence_create_blogpost: confluenceCreateBlogPostTool,
confluence_list_blogposts_in_space: confluenceListBlogPostsInSpaceTool,
confluence_search: confluenceSearchTool,
confluence_search_in_space: confluenceSearchInSpaceTool,
confluence_create_comment: confluenceCreateCommentTool,
confluence_list_comments: confluenceListCommentsTool,
confluence_update_comment: confluenceUpdateCommentTool,
@@ -2617,6 +2642,7 @@ export const tools: Record<string, ToolConfig> = {
confluence_upload_attachment: confluenceUploadAttachmentTool,
confluence_delete_attachment: confluenceDeleteAttachmentTool,
confluence_list_labels: confluenceListLabelsTool,
confluence_add_label: confluenceAddLabelTool,
confluence_get_space: confluenceGetSpaceTool,
confluence_list_spaces: confluenceListSpacesTool,
cursor_list_agents: cursorListAgentsTool,