Skip to content
Open
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
22 changes: 18 additions & 4 deletions src/app/api/agents/[id]/heartbeat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { requireRole } from '@/lib/auth';
import { agentHeartbeatLimiter } from '@/lib/rate-limit';
import { logger } from '@/lib/logger';
import { resolveTaskImplementationTarget } from '@/lib/task-routing';
import { requireAgentSelfAccess, requireWorkspaceId } from '@/lib/enforcement/workspace-scope';

/**
* GET /api/agents/[id]/heartbeat - Agent heartbeat check
Expand All @@ -26,8 +27,14 @@ export async function GET(
const db = getDatabase();
const resolvedParams = await params;
const agentId = resolvedParams.id;
const workspaceId = auth.user.workspace_id ?? 1;

const wsResult = requireWorkspaceId(auth.user);
if (!('workspaceId' in wsResult)) return wsResult.response;
const { workspaceId } = wsResult;

// Agent key resource scoping: an agent key may only read its own heartbeat.
const selfDeny = requireAgentSelfAccess(auth.user, agentId);
if (selfDeny) return selfDeny;

// Get agent by ID or name
let agent: any;
if (isNaN(Number(agentId))) {
Expand All @@ -37,7 +44,7 @@ export async function GET(
// Lookup by ID
agent = db.prepare('SELECT * FROM agents WHERE id = ? AND workspace_id = ?').get(Number(agentId), workspaceId);
}

if (!agent) {
return NextResponse.json({ error: 'Agent not found' }, { status: 404 });
}
Expand Down Expand Up @@ -195,6 +202,11 @@ export async function POST(
const rateLimited = agentHeartbeatLimiter(request);
if (rateLimited) return rateLimited;

// Agent key resource scoping: an agent key may only update its own heartbeat.
const routeParams = await params;
const selfDeny = requireAgentSelfAccess(auth.user, routeParams.id);
if (selfDeny) return selfDeny;

let body: any = {};
try {
body = await request.json();
Expand All @@ -205,7 +217,9 @@ export async function POST(
const { connection_id, token_usage } = body;
const db = getDatabase();
const now = Math.floor(Date.now() / 1000);
const workspaceId = auth.user.workspace_id ?? 1;
const wsResult = requireWorkspaceId(auth.user);
if (!('workspaceId' in wsResult)) return wsResult.response;
const { workspaceId } = wsResult;

// Update direct connection heartbeat if connection_id provided
if (connection_id) {
Expand Down
19 changes: 16 additions & 3 deletions src/app/api/agents/[id]/memory/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { getDatabase, db_helpers } from '@/lib/db';
import { requireRole } from '@/lib/auth';
import { logger } from '@/lib/logger';
import { requireAgentSelfAccess, requireWorkspaceId } from '@/lib/enforcement/workspace-scope';
import { statSync, mkdirSync, writeFileSync } from 'node:fs';
import { dirname } from 'node:path';
import { resolveWithin } from '@/lib/paths';
Expand Down Expand Up @@ -43,7 +44,11 @@ export async function GET(
const db = getDatabase();
const resolvedParams = await params;
const agentId = resolvedParams.id;
const workspaceId = auth.user.workspace_id ?? 1;
const wsResult = requireWorkspaceId(auth.user);
if (!('workspaceId' in wsResult)) return wsResult.response;
const { workspaceId } = wsResult;
const selfDeny = requireAgentSelfAccess(auth.user, agentId);
if (selfDeny) return selfDeny;

const agent = getAgentByIdOrName(db, agentId, workspaceId);
if (!agent) {
Expand Down Expand Up @@ -117,7 +122,11 @@ export async function PUT(
const db = getDatabase();
const resolvedParams = await params;
const agentId = resolvedParams.id;
const workspaceId = auth.user.workspace_id ?? 1;
const wsResult = requireWorkspaceId(auth.user);
if (!('workspaceId' in wsResult)) return wsResult.response;
const { workspaceId } = wsResult;
const selfDeny = requireAgentSelfAccess(auth.user, agentId);
if (selfDeny) return selfDeny;
const body = await request.json();
const { working_memory, append } = body;

Expand Down Expand Up @@ -220,7 +229,11 @@ export async function DELETE(
const db = getDatabase();
const resolvedParams = await params;
const agentId = resolvedParams.id;
const workspaceId = auth.user.workspace_id ?? 1;
const wsResult = requireWorkspaceId(auth.user);
if (!('workspaceId' in wsResult)) return wsResult.response;
const { workspaceId } = wsResult;
const selfDeny = requireAgentSelfAccess(auth.user, agentId);
if (selfDeny) return selfDeny;

const agent = getAgentByIdOrName(db, agentId, workspaceId);
if (!agent) {
Expand Down
23 changes: 17 additions & 6 deletions src/app/api/tasks/[id]/comments/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { validateBody, createCommentSchema } from '@/lib/validation';
import { mutationLimiter } from '@/lib/rate-limit';
import { logger } from '@/lib/logger';
import { resolveMentionRecipients } from '@/lib/mentions';
import { requireAgentTaskAccess, requireWorkspaceId } from '@/lib/enforcement/workspace-scope';

/**
* GET /api/tasks/[id]/comments - Get all comments for a task
Expand All @@ -20,20 +21,25 @@ export async function GET(
const db = getDatabase();
const resolvedParams = await params;
const taskId = parseInt(resolvedParams.id);
const workspaceId = auth.user.workspace_id ?? 1;
const wsResult = requireWorkspaceId(auth.user);
if (!('workspaceId' in wsResult)) return wsResult.response;
const { workspaceId } = wsResult;

if (isNaN(taskId)) {
return NextResponse.json({ error: 'Invalid task ID' }, { status: 400 });
}

// Verify task exists
const task = db
.prepare('SELECT id FROM tasks WHERE id = ? AND workspace_id = ?')
.get(taskId, workspaceId);
.prepare('SELECT id, assigned_to FROM tasks WHERE id = ? AND workspace_id = ?')
.get(taskId, workspaceId) as { id: number; assigned_to: string | null } | undefined;
if (!task) {
return NextResponse.json({ error: 'Task not found' }, { status: 404 });
}


const taskDeny = requireAgentTaskAccess(auth.user, task.assigned_to);
if (taskDeny) return taskDeny;

// Get comments ordered by creation time
const stmt = db.prepare(`
SELECT * FROM comments
Expand Down Expand Up @@ -101,7 +107,9 @@ export async function POST(
const db = getDatabase();
const resolvedParams = await params;
const taskId = parseInt(resolvedParams.id);
const workspaceId = auth.user.workspace_id ?? 1;
const wsResult = requireWorkspaceId(auth.user);
if (!('workspaceId' in wsResult)) return wsResult.response;
const { workspaceId } = wsResult;

if (isNaN(taskId)) {
return NextResponse.json({ error: 'Invalid task ID' }, { status: 400 });
Expand Down Expand Up @@ -141,7 +149,10 @@ export async function POST(
if (!task) {
return NextResponse.json({ error: 'Task not found' }, { status: 404 });
}


const taskDeny = requireAgentTaskAccess(auth.user, task.assigned_to ?? null);
if (taskDeny) return taskDeny;

// Verify parent comment exists if specified
if (parent_id) {
const parentComment = db
Expand Down
30 changes: 23 additions & 7 deletions src/app/api/tasks/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { reconcileDeferredTaskCompletions } from '@/lib/task-dispatch';
import { syncTaskOutbound } from '@/lib/github-sync-engine';
import { removeTaskFromGnap } from '@/lib/gnap-sync';
import { config } from '@/lib/config';
import { requireAgentTaskAccess, requireWorkspaceId } from '@/lib/enforcement/workspace-scope';

function formatTicketRef(prefix?: string | null, num?: number | null): string | undefined {
if (!prefix || typeof num !== 'number' || !Number.isFinite(num) || num <= 0) return undefined
Expand Down Expand Up @@ -54,7 +55,9 @@ export async function GET(
const db = getDatabase();
const resolvedParams = await params;
const taskId = parseInt(resolvedParams.id);
const workspaceId = auth.user.workspace_id ?? 1;
const wsResult = requireWorkspaceId(auth.user);
if (!('workspaceId' in wsResult)) return wsResult.response;
const { workspaceId } = wsResult;

if (isNaN(taskId)) {
return NextResponse.json({ error: 'Invalid task ID' }, { status: 400 });
Expand All @@ -77,10 +80,13 @@ export async function GET(
if (!task) {
return NextResponse.json({ error: 'Task not found' }, { status: 404 });
}


const taskDeny = requireAgentTaskAccess(auth.user, (task as Task).assigned_to ?? null);
if (taskDeny) return taskDeny;

// Parse JSON fields
const taskWithParsedData = mapTaskRow(task);

return NextResponse.json({ task: taskWithParsedData });
} catch (error) {
logger.error({ err: error }, 'GET /api/tasks/[id] error');
Expand All @@ -105,7 +111,9 @@ export async function PUT(
const db = getDatabase();
const resolvedParams = await params;
const taskId = parseInt(resolvedParams.id);
const workspaceId = auth.user.workspace_id ?? 1;
const wsResult = requireWorkspaceId(auth.user);
if (!('workspaceId' in wsResult)) return wsResult.response;
const { workspaceId } = wsResult;
const validated = await validateBody(request, updateTaskSchema);
if ('error' in validated) return validated.error;
const body = validated.data;
Expand All @@ -122,7 +130,10 @@ export async function PUT(
if (!currentTask) {
return NextResponse.json({ error: 'Task not found' }, { status: 404 });
}


const taskDeny = requireAgentTaskAccess(auth.user, currentTask.assigned_to ?? null);
if (taskDeny) return taskDeny;

const {
title,
description,
Expand Down Expand Up @@ -430,7 +441,9 @@ export async function DELETE(
const db = getDatabase();
const resolvedParams = await params;
const taskId = parseInt(resolvedParams.id);
const workspaceId = auth.user.workspace_id ?? 1;
const wsResult = requireWorkspaceId(auth.user);
if (!('workspaceId' in wsResult)) return wsResult.response;
const { workspaceId } = wsResult;

if (isNaN(taskId)) {
return NextResponse.json({ error: 'Invalid task ID' }, { status: 400 });
Expand All @@ -444,7 +457,10 @@ export async function DELETE(
if (!task) {
return NextResponse.json({ error: 'Task not found' }, { status: 404 });
}


const taskDeny = requireAgentTaskAccess(auth.user, (task as Task).assigned_to ?? null);
if (taskDeny) return taskDeny;

// Delete task (cascades will handle comments)
const stmt = db.prepare('DELETE FROM tasks WHERE id = ? AND workspace_id = ?');
stmt.run(taskId, workspaceId);
Expand Down
10 changes: 9 additions & 1 deletion src/app/api/tasks/queue/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { getDatabase } from '@/lib/db'
import { requireRole } from '@/lib/auth'
import { agentTaskLimiter } from '@/lib/rate-limit'
import { logger } from '@/lib/logger'
import { requireWorkspaceId } from '@/lib/enforcement/workspace-scope'

type QueueReason = 'continue_current' | 'assigned' | 'at_capacity' | 'no_tasks_available'

Expand Down Expand Up @@ -51,7 +52,9 @@ export async function GET(request: NextRequest) {

try {
const db = getDatabase()
const workspaceId = auth.user.workspace_id
const wsResult = requireWorkspaceId(auth.user)
if (!('workspaceId' in wsResult)) return wsResult.response
const { workspaceId } = wsResult
const { searchParams } = new URL(request.url)

const agent =
Expand All @@ -62,6 +65,11 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'Missing agent. Provide ?agent=... or x-agent-name header.' }, { status: 400 })
}

// Agent keys (non-admin) may only queue for themselves
if (auth.user.agent_name && auth.user.role !== 'admin' && agent !== auth.user.agent_name) {
return NextResponse.json({ error: 'Access denied: agent key may only queue tasks for itself.' }, { status: 403 })
}

const maxCapacityRaw = searchParams.get('max_capacity') || '1'
if (!/^\d+$/.test(maxCapacityRaw)) {
return NextResponse.json({ error: 'Invalid max_capacity. Expected integer 1..20.' }, { status: 400 })
Expand Down
30 changes: 24 additions & 6 deletions src/app/api/tasks/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { reconcileDeferredTaskCompletions } from '@/lib/task-dispatch';
import { pushTaskToGitHub, syncTaskOutbound } from '@/lib/github-sync-engine';
import { pushTaskToGnap } from '@/lib/gnap-sync';
import { config } from '@/lib/config';
import { requireWorkspaceId } from '@/lib/enforcement/workspace-scope';

function formatTicketRef(prefix?: string | null, num?: number | null): string | undefined {
if (!prefix || typeof num !== 'number' || !Number.isFinite(num) || num <= 0) return undefined
Expand Down Expand Up @@ -69,7 +70,9 @@ export async function GET(request: NextRequest) {

try {
const db = getDatabase();
const workspaceId = auth.user.workspace_id;
const wsResult = requireWorkspaceId(auth.user);
if (!('workspaceId' in wsResult)) return wsResult.response;
const { workspaceId } = wsResult;
const { searchParams } = new URL(request.url);

// Parse query parameters
Expand Down Expand Up @@ -102,11 +105,19 @@ export async function GET(request: NextRequest) {
params.push(status);
}

if (assigned_to) {
// Agent keys (non-admin) may only list their own tasks
const agentScope = auth.user.agent_name && auth.user.role !== 'admin'
? auth.user.agent_name
: null;

if (agentScope) {
query += ' AND t.assigned_to = ?';
params.push(agentScope);
} else if (assigned_to) {
query += ' AND t.assigned_to = ?';
params.push(assigned_to);
}

if (priority) {
query += ' AND t.priority = ?';
params.push(priority);
Expand All @@ -133,7 +144,10 @@ export async function GET(request: NextRequest) {
countQuery += ' AND status = ?';
countParams.push(status);
}
if (assigned_to) {
if (agentScope) {
countQuery += ' AND assigned_to = ?';
countParams.push(agentScope);
} else if (assigned_to) {
countQuery += ' AND assigned_to = ?';
countParams.push(assigned_to);
}
Expand Down Expand Up @@ -166,7 +180,9 @@ export async function POST(request: NextRequest) {

try {
const db = getDatabase();
const workspaceId = auth.user.workspace_id;
const wsResult = requireWorkspaceId(auth.user);
if (!('workspaceId' in wsResult)) return wsResult.response;
const { workspaceId } = wsResult;
const validated = await validateBody(request, createTaskSchema);
if ('error' in validated) return validated.error;
const body = validated.data;
Expand Down Expand Up @@ -352,7 +368,9 @@ export async function PUT(request: NextRequest) {

try {
const db = getDatabase();
const workspaceId = auth.user.workspace_id;
const wsResult = requireWorkspaceId(auth.user);
if (!('workspaceId' in wsResult)) return wsResult.response;
const { workspaceId } = wsResult;
const validated = await validateBody(request, bulkUpdateTaskStatusSchema);
if ('error' in validated) return validated.error;
const { tasks } = validated.data;
Expand Down
Loading