Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { authenticateRequestWithOptions, isAuthError } from '@/lib/auth';
import { db } from '@pagespace/db';
import { loggers } from '@pagespace/lib/server';
import { canUserEditPage } from '@pagespace/lib/permissions';
import { getGrantById, updateGrant, deleteGrant } from '@pagespace/lib/integrations';

const AUTH_OPTIONS_WRITE = { allow: ['session'] as const, requireCSRF: true };

const updateGrantSchema = z.object({
allowedTools: z.array(z.string()).nullable().optional(),
deniedTools: z.array(z.string()).nullable().optional(),
readOnly: z.boolean().optional(),
rateLimitOverride: z.object({
requestsPerMinute: z.number().min(1).max(1000).optional(),
}).nullable().optional(),
});

/**
* PUT /api/agents/[agentId]/integrations/[grantId]
* Update an integration grant's tool permissions.
*/
export async function PUT(
request: Request,
context: { params: Promise<{ agentId: string; grantId: string }> }
) {
const { agentId, grantId } = await context.params;
const auth = await authenticateRequestWithOptions(request, AUTH_OPTIONS_WRITE);
if (isAuthError(auth)) return auth.error;

try {
const canEdit = await canUserEditPage(auth.userId, agentId);
if (!canEdit) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 });
}

const grant = await getGrantById(db, grantId);
if (!grant || grant.agentId !== agentId) {
return NextResponse.json({ error: 'Grant not found' }, { status: 404 });
}

const body = await request.json();
const validation = updateGrantSchema.safeParse(body);
if (!validation.success) {
return NextResponse.json(
{ error: 'Validation failed', details: validation.error.flatten().fieldErrors },
{ status: 400 }
);
}

const updated = await updateGrant(db, grantId, validation.data);
return NextResponse.json({ grant: updated });
} catch (error) {
loggers.api.error('Error updating agent integration grant:', error as Error);
return NextResponse.json({ error: 'Failed to update grant' }, { status: 500 });
}
}

/**
* DELETE /api/agents/[agentId]/integrations/[grantId]
* Remove an integration grant.
*/
export async function DELETE(
request: Request,
context: { params: Promise<{ agentId: string; grantId: string }> }
) {
const { agentId, grantId } = await context.params;
const auth = await authenticateRequestWithOptions(request, AUTH_OPTIONS_WRITE);
if (isAuthError(auth)) return auth.error;

try {
const canEdit = await canUserEditPage(auth.userId, agentId);
if (!canEdit) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 });
}

const grant = await getGrantById(db, grantId);
if (!grant || grant.agentId !== agentId) {
return NextResponse.json({ error: 'Grant not found' }, { status: 404 });
}

await deleteGrant(db, grantId);
return NextResponse.json({ success: true });
} catch (error) {
loggers.api.error('Error deleting agent integration grant:', error as Error);
return NextResponse.json({ error: 'Failed to delete grant' }, { status: 500 });
}
}
146 changes: 146 additions & 0 deletions apps/web/src/app/api/agents/[agentId]/integrations/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { authenticateRequestWithOptions, isAuthError } from '@/lib/auth';
import { db } from '@pagespace/db';
import { loggers } from '@pagespace/lib/server';
import { canUserEditPage } from '@pagespace/lib/permissions';
import { getDriveAccess } from '@pagespace/lib/services/drive-service';
import {
listGrantsByAgent,
createGrant,
getConnectionById,
findGrant,
} from '@pagespace/lib/integrations';

const AUTH_OPTIONS_READ = { allow: ['session'] as const };
const AUTH_OPTIONS_WRITE = { allow: ['session'] as const, requireCSRF: true };

const createGrantSchema = z.object({
connectionId: z.string().min(1),
allowedTools: z.array(z.string()).nullable().optional().default(null),
deniedTools: z.array(z.string()).nullable().optional().default(null),
readOnly: z.boolean().optional().default(false),
rateLimitOverride: z.object({
requestsPerMinute: z.number().min(1).max(1000).optional(),
}).nullable().optional(),
});

/**
* GET /api/agents/[agentId]/integrations
* List all integration grants for an agent.
*/
export async function GET(
request: Request,
context: { params: Promise<{ agentId: string }> }
) {
const { agentId } = await context.params;
const auth = await authenticateRequestWithOptions(request, AUTH_OPTIONS_READ);
if (isAuthError(auth)) return auth.error;

try {
// Verify user can view the agent
const canEdit = await canUserEditPage(auth.userId, agentId);
if (!canEdit) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 });
}

const grants = await listGrantsByAgent(db, agentId);

return NextResponse.json({
grants: grants.map((g) => ({
id: g.id,
agentId: g.agentId,
connectionId: g.connectionId,
allowedTools: g.allowedTools,
deniedTools: g.deniedTools,
readOnly: g.readOnly,
rateLimitOverride: g.rateLimitOverride,
createdAt: g.createdAt,
connection: g.connection ? {
id: g.connection.id,
name: g.connection.name,
status: g.connection.status,
provider: g.connection.provider ? {
slug: g.connection.provider.slug,
name: g.connection.provider.name,
} : null,
} : null,
})),
});
} catch (error) {
loggers.api.error('Error listing agent integration grants:', error as Error);
return NextResponse.json({ error: 'Failed to list grants' }, { status: 500 });
}
}

/**
* POST /api/agents/[agentId]/integrations
* Create a new integration grant for an agent.
*/
export async function POST(
request: Request,
context: { params: Promise<{ agentId: string }> }
) {
const { agentId } = await context.params;
const auth = await authenticateRequestWithOptions(request, AUTH_OPTIONS_WRITE);
if (isAuthError(auth)) return auth.error;

try {
const canEdit = await canUserEditPage(auth.userId, agentId);
if (!canEdit) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 });
}

const body = await request.json();
const validation = createGrantSchema.safeParse(body);
if (!validation.success) {
return NextResponse.json(
{ error: 'Validation failed', details: validation.error.flatten().fieldErrors },
{ status: 400 }
);
}

const { connectionId, allowedTools, deniedTools, readOnly, rateLimitOverride } = validation.data;

// Verify connection exists and is active
const connection = await getConnectionById(db, connectionId);
if (!connection) {
return NextResponse.json({ error: 'Connection not found' }, { status: 404 });
}
if (connection.status !== 'active') {
return NextResponse.json({ error: 'Connection is not active' }, { status: 400 });
}

// Verify the requesting user owns this connection (user-scoped)
// or is a member of the drive that owns it (drive-scoped)
const isUserConnection = connection.userId === auth.userId;
let isDriveMember = false;
if (connection.driveId) {
const access = await getDriveAccess(connection.driveId, auth.userId);
isDriveMember = access.isMember;
}
if (!isUserConnection && !isDriveMember) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 });
}

// Check for existing grant
const existing = await findGrant(db, agentId, connectionId);
if (existing) {
return NextResponse.json({ error: 'Grant already exists for this connection' }, { status: 409 });
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const grant = await createGrant(db, {
agentId,
connectionId,
allowedTools,
deniedTools,
readOnly,
rateLimitOverride,
});

return NextResponse.json({ grant }, { status: 201 });
} catch (error) {
loggers.api.error('Error creating agent integration grant:', error as Error);
return NextResponse.json({ error: 'Failed to create grant' }, { status: 500 });
}
}
20 changes: 20 additions & 0 deletions apps/web/src/app/api/ai/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,26 @@ export async function POST(request: Request) {
});
}

// INTEGRATION TOOLS: Resolve and merge integration tools for this agent
try {
const { resolvePageAgentIntegrationTools } = await import('@/lib/ai/core/integration-tool-resolver');
const integrationTools = await resolvePageAgentIntegrationTools({
agentId: chatId,
userId,
driveId: page.driveId,
});
if (Object.keys(integrationTools).length > 0) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
filteredTools = { ...filteredTools, ...integrationTools } as any;
loggers.ai.info('AI Chat API: Merged integration tools', {
integrationToolCount: Object.keys(integrationTools).length,
totalTools: Object.keys(filteredTools).length,
});
}
} catch (error) {
loggers.ai.error('AI Chat API: Failed to resolve integration tools', error as Error);
}

// DESKTOP MCP INTEGRATION: Merge MCP tools from client if provided
if (mcpTools && mcpTools.length > 0) {
try {
Expand Down
32 changes: 32 additions & 0 deletions apps/web/src/app/api/ai/global/[id]/messages/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import { maskIdentifier } from '@/lib/logging/mask';
import type { MCPTool } from '@/types/mcp';
import { AIMonitoring } from '@pagespace/lib/ai-monitoring';
import { calculateTotalContextSize } from '@pagespace/lib/ai-context-calculator';
import { getDriveAccess } from '@pagespace/lib/services/drive-service';
import { parseBoundedIntParam } from '@/lib/utils/query-params';
import {
createStreamAbortController,
Expand Down Expand Up @@ -683,6 +684,37 @@ MENTION PROCESSING:
totalTools: Object.keys(finalTools).length
});

// INTEGRATION TOOLS: Resolve and merge integration tools for global assistant
try {
const { resolveGlobalAssistantIntegrationTools } = await import('@/lib/ai/core/integration-tool-resolver');
let currentDriveId = locationContext?.currentDrive?.id || null;
let userDriveRole: 'OWNER' | 'ADMIN' | 'MEMBER' | null = null;
if (currentDriveId) {
const access = await getDriveAccess(currentDriveId, userId);
if (!access.isMember) {
// User is not a member of this drive — do not resolve drive-scoped integrations
currentDriveId = null;
} else {
userDriveRole = access.role;
}
}
const integrationTools = await resolveGlobalAssistantIntegrationTools({
userId,
driveId: currentDriveId,
userDriveRole,
});
if (Object.keys(integrationTools).length > 0) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
finalTools = { ...finalTools, ...integrationTools } as any;
loggers.api.info('Global Assistant: Merged integration tools', {
integrationToolCount: Object.keys(integrationTools).length,
totalTools: Object.keys(finalTools).length,
});
}
} catch (error) {
loggers.api.error('Global Assistant: Failed to resolve integration tools', error as Error);
}

// Merge MCP tools if provided
if (mcpTools && mcpTools.length > 0) {
try {
Expand Down
Loading