Skip to content
Closed
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,2 @@
-- AlterTable
ALTER TABLE "features" ADD COLUMN "verify_callback_key" TEXT;
1 change: 1 addition & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -920,6 +920,7 @@ model Feature {
isFastTrack Boolean @default(false) @map("is_fast_track")
planUpdatedAt DateTime? @map("plan_updated_at")
model String?
verifyCallbackKey String? @map("verify_callback_key")
milestoneId String? @map("milestone_id")
initiativeId String? @map("initiative_id")
// Cuids of features that must reach completion before this feature
Expand Down
149 changes: 149 additions & 0 deletions src/app/api/features/[featureId]/verify/callback/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { EncryptionService, timingSafeEqual } from "@/lib/encryption";
import { ChatRole, ChatStatus, ArtifactType, WorkflowStatus, NotificationTriggerType } from "@prisma/client";
import { createAndSendNotification } from "@/services/notifications";
import { pusherServer, getFeatureChannelName, PUSHER_EVENTS } from "@/lib/pusher";
import type { VerifyCallbackPayload } from "@/services/attestor/types";

export const fetchCache = "force-no-store";

const encryptionService = EncryptionService.getInstance();

export async function POST(request: NextRequest, { params }: { params: Promise<{ featureId: string }> }) {
try {
const { featureId } = await params;

if (!featureId) {
return NextResponse.json({ error: "Feature ID required" }, { status: 400 });
}

const apiKey = request.headers.get("x-api-key");
if (!apiKey) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const feature = await db.feature.findUnique({
where: { id: featureId, deleted: false },
select: {
id: true,
title: true,
verifyCallbackKey: true,
assigneeId: true,
createdById: true,
workspaceId: true,
workspace: { select: { slug: true } },
},
});

if (!feature) {
return NextResponse.json({ error: "Feature not found" }, { status: 404 });
}

if (!feature.verifyCallbackKey) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

let decryptedKey: string;
try {
const encryptedData = JSON.parse(feature.verifyCallbackKey);
decryptedKey = encryptionService.decryptField("agentPassword", encryptedData);
} catch (error) {
console.error("[attestor] Failed to decrypt verifyCallbackKey:", error);
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

if (!timingSafeEqual(apiKey, decryptedKey)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

let payload: VerifyCallbackPayload;
try {
payload = (await request.json()) as VerifyCallbackPayload;
} catch (error) {
console.error("[attestor] Failed to parse callback payload:", error);
return NextResponse.json({ error: "Invalid payload" }, { status: 400 });
}

const passed = payload.overall === "passed";
const workflowStatus = passed ? WorkflowStatus.COMPLETED : WorkflowStatus.FAILED;

const chatMessage = await db.chatMessage.create({
data: {
featureId,
message: passed ? "Verification passed" : "Verification needs review",
role: ChatRole.ASSISTANT,
status: ChatStatus.SENT,
artifacts: {
create: [
{
type: ArtifactType.VERIFY,
content: payload as unknown as object,
icon: "verify",
},
],
},
},
include: { artifacts: true },
});

await db.feature.update({
where: { id: featureId },
data: {
workflowStatus,
workflowCompletedAt: new Date(),
verifyCallbackKey: null,
},
});

if (!passed) {
const targetUserId = feature.assigneeId ?? feature.createdById;
const featureUrl = `${process.env.NEXTAUTH_URL}/w/${feature.workspace.slug}/plan/${featureId}`;
void (async () => {
try {
const targetUser = await db.user.findUnique({
where: { id: targetUserId },
select: { sphinxAlias: true, name: true },
});
const alias = targetUser?.sphinxAlias ?? targetUser?.name ?? "User";
await createAndSendNotification({
targetUserId,
featureId,
workspaceId: feature.workspaceId,
notificationType: NotificationTriggerType.WORKFLOW_HALTED,
message: `@${alias} — Verification of '${feature.title}' needs review: ${featureUrl}`,
});
} catch (notifError) {
console.error("[attestor] Error firing WORKFLOW_HALTED notification:", notifError);
}
})();
}

try {
void pusherServer
.trigger(getFeatureChannelName(featureId), PUSHER_EVENTS.WORKFLOW_STATUS_UPDATE, {
featureId,
workflowStatus,
overall: payload.overall,
at: Date.now(),
})
.catch((err) => {
console.error("[attestor] Pusher broadcast failed (non-fatal):", err);
});
} catch (err) {
console.error("[attestor] Pusher broadcast threw (non-fatal):", err);
}

return NextResponse.json({
success: true,
data: {
messageId: chatMessage.id,
artifactIds: chatMessage.artifacts.map((a) => a.id),
workflowStatus,
},
});
} catch (error) {
console.error("[attestor] Unexpected error in verify callback:", error);
return NextResponse.json({ error: "Internal error" }, { status: 500 });
}
}
81 changes: 81 additions & 0 deletions src/app/api/features/[featureId]/verify/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { ArtifactType } from "@prisma/client";
import {
resolveWorkspaceAccess,
requireMemberAccess,
requireReadAccess,
} from "@/lib/auth/workspace-access";
import { startVerification } from "@/services/attestor/trigger";

export const fetchCache = "force-no-store";

export async function POST(request: NextRequest, { params }: { params: Promise<{ featureId: string }> }) {
try {
const { featureId } = await params;

const feature = await db.feature.findUnique({
where: { id: featureId, deleted: false },
select: { workspaceId: true },
});

if (!feature) {
return NextResponse.json({ error: "Feature not found" }, { status: 404 });
}

const access = await resolveWorkspaceAccess(request, { workspaceId: feature.workspaceId });
const member = requireMemberAccess(access);
if (member instanceof NextResponse) return member;

const result = await startVerification(featureId, member.userId);

return NextResponse.json({ success: true, data: result });
} catch (error) {
console.error("[attestor] verify trigger failed:", error);
return NextResponse.json(
{
error: "Failed to start verification",
details: error instanceof Error ? error.message : "Unknown error",
},
{ status: 500 },
);
}
}

export async function GET(request: NextRequest, { params }: { params: Promise<{ featureId: string }> }) {
try {
const { featureId } = await params;

const feature = await db.feature.findUnique({
where: { id: featureId, deleted: false },
select: { workspaceId: true, workflowStatus: true },
});

if (!feature) {
return NextResponse.json({ error: "Feature not found" }, { status: 404 });
}

const access = await resolveWorkspaceAccess(request, { workspaceId: feature.workspaceId });
const ok = requireReadAccess(access);
if (ok instanceof NextResponse) return ok;

const artifact = await db.artifact.findFirst({
where: {
type: ArtifactType.VERIFY,
message: { featureId },
},
orderBy: { createdAt: "desc" },
select: { id: true, content: true, createdAt: true },
});

return NextResponse.json({
workflowStatus: feature.workflowStatus,
checklist: artifact?.content ?? null,
artifactId: artifact?.id ?? null,
createdAt: artifact?.createdAt ?? null,
});
} catch (error) {
console.error("[attestor] verify fetch failed:", error);
return NextResponse.json({ error: "Internal error" }, { status: 500 });
}
}
Loading
Loading