Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
5cc14fd
Generated with Hive: Fix model validation to accept provider/name str…
pitoi Apr 22, 2026
6ec7776
Generated with Hive: Fix test mocks and workspace member pool creatio…
pitoi Apr 22, 2026
1576945
Merge branch 'master' into bugfix/cmo9ffuvl000bjq04qc9m4gbr-model-val…
tomsmith8 Apr 22, 2026
a27a3b1
Merge branch 'master' into bugfix/cmo9ffuvl000bjq04qc9m4gbr-model-val…
tomsmith8 Apr 22, 2026
2f5a175
Merge branch 'master' into bugfix/cmo9ffuvl000bjq04qc9m4gbr-model-val…
tomsmith8 Apr 22, 2026
db6eef3
Merge branch 'master' into bugfix/cmo9ffuvl000bjq04qc9m4gbr-model-val…
tomsmith8 Apr 22, 2026
3317242
Merge branch 'master' into bugfix/cmo9ffuvl000bjq04qc9m4gbr-model-val…
tomsmith8 Apr 22, 2026
ab132e5
Merge branch 'master' into bugfix/cmo9ffuvl000bjq04qc9m4gbr-model-val…
tomsmith8 Apr 23, 2026
d524a5c
Merge branch 'master' into bugfix/cmo9ffuvl000bjq04qc9m4gbr-model-val…
tomsmith8 Apr 23, 2026
4c5ba46
Merge branch 'master' into bugfix/cmo9ffuvl000bjq04qc9m4gbr-model-val…
tomsmith8 Apr 23, 2026
8510116
Merge branch 'master' into bugfix/cmo9ffuvl000bjq04qc9m4gbr-model-val…
tomsmith8 Apr 24, 2026
2f06ca9
Merge branch 'master' into bugfix/cmo9ffuvl000bjq04qc9m4gbr-model-val…
tomsmith8 Apr 24, 2026
11dc440
Merge branch 'master' into bugfix/cmo9ffuvl000bjq04qc9m4gbr-model-val…
tomsmith8 Apr 24, 2026
129f408
Merge branch 'master' into bugfix/cmo9ffuvl000bjq04qc9m4gbr-model-val…
tomsmith8 Apr 24, 2026
d818c7e
Merge branch 'master' into bugfix/cmo9ffuvl000bjq04qc9m4gbr-model-val…
tomsmith8 Apr 24, 2026
106d3c8
Merge branch 'master' into bugfix/cmo9ffuvl000bjq04qc9m4gbr-model-val…
tomsmith8 Apr 25, 2026
53385f0
Merge branch 'master' into bugfix/cmo9ffuvl000bjq04qc9m4gbr-model-val…
tomsmith8 Apr 25, 2026
b53b14e
Merge branch 'master' into bugfix/cmo9ffuvl000bjq04qc9m4gbr-model-val…
tomsmith8 Apr 25, 2026
8b3e408
Merge branch 'master' into bugfix/cmo9ffuvl000bjq04qc9m4gbr-model-val…
tomsmith8 Apr 25, 2026
f36244c
Merge branch 'master' into bugfix/cmo9ffuvl000bjq04qc9m4gbr-model-val…
tomsmith8 Apr 25, 2026
41c9ef9
Merge branch 'master' into bugfix/cmo9ffuvl000bjq04qc9m4gbr-model-val…
tomsmith8 Apr 25, 2026
6f9f76e
Merge branch 'master' into bugfix/cmo9ffuvl000bjq04qc9m4gbr-model-val…
tomsmith8 Apr 25, 2026
3001a41
Merge branch 'master' into bugfix/cmo9ffuvl000bjq04qc9m4gbr-model-val…
tomsmith8 Apr 26, 2026
e3be97c
Merge branch 'master' into bugfix/cmo9ffuvl000bjq04qc9m4gbr-model-val…
tomsmith8 Apr 26, 2026
374056a
Merge branch 'master' into bugfix/cmo9ffuvl000bjq04qc9m4gbr-model-val…
tomsmith8 Apr 26, 2026
ed1e49f
Merge branch 'master' into bugfix/cmo9ffuvl000bjq04qc9m4gbr-model-val…
tomsmith8 Apr 26, 2026
d235b3e
Merge branch 'master' into bugfix/cmo9ffuvl000bjq04qc9m4gbr-model-val…
tomsmith8 Apr 26, 2026
0666a5a
Merge branch 'master' into bugfix/cmo9ffuvl000bjq04qc9m4gbr-model-val…
tomsmith8 Apr 26, 2026
a7346e0
Merge branch 'master' into bugfix/cmo9ffuvl000bjq04qc9m4gbr-model-val…
tomsmith8 Apr 26, 2026
76dc371
Merge branch 'master' into bugfix/cmo9ffuvl000bjq04qc9m4gbr-model-val…
tomsmith8 Apr 27, 2026
3237bd4
Merge branch 'master' into bugfix/cmo9ffuvl000bjq04qc9m4gbr-model-val…
tomsmith8 Apr 27, 2026
b8b7a5b
Merge branch 'master' into bugfix/cmo9ffuvl000bjq04qc9m4gbr-model-val…
tomsmith8 Apr 27, 2026
57297e6
Merge branch 'master' into bugfix/cmo9ffuvl000bjq04qc9m4gbr-model-val…
tomsmith8 Apr 27, 2026
a71fe5d
Merge branch 'master' into bugfix/cmo9ffuvl000bjq04qc9m4gbr-model-val…
tomsmith8 Apr 27, 2026
207bd2a
Merge branch 'master' into bugfix/cmo9ffuvl000bjq04qc9m4gbr-model-val…
tomsmith8 Apr 27, 2026
da7e2e9
Merge branch 'master' into bugfix/cmo9ffuvl000bjq04qc9m4gbr-model-val…
tomsmith8 Apr 27, 2026
5bfb4fb
Merge branch 'master' into bugfix/cmo9ffuvl000bjq04qc9m4gbr-model-val…
tomsmith8 Apr 27, 2026
ebd6cf8
Merge branch 'master' into bugfix/cmo9ffuvl000bjq04qc9m4gbr-model-val…
tomsmith8 Apr 27, 2026
d294fa0
Merge branch 'master' into bugfix/cmo9ffuvl000bjq04qc9m4gbr-model-val…
tomsmith8 Apr 28, 2026
1e690a0
Merge branch 'master' into bugfix/cmo9ffuvl000bjq04qc9m4gbr-model-val…
tomsmith8 Apr 28, 2026
e6df258
Merge branch 'master' into bugfix/cmo9ffuvl000bjq04qc9m4gbr-model-val…
tomsmith8 Apr 28, 2026
0f97a3d
Merge branch 'master' into bugfix/cmo9ffuvl000bjq04qc9m4gbr-model-val…
tomsmith8 Apr 28, 2026
84a64fc
Merge branch 'master' into bugfix/cmo9ffuvl000bjq04qc9m4gbr-model-val…
tomsmith8 Apr 28, 2026
8f9b434
Merge branch 'master' into bugfix/cmo9ffuvl000bjq04qc9m4gbr-model-val…
tomsmith8 Apr 28, 2026
ff229a0
Merge branch 'master' into bugfix/cmo9ffuvl000bjq04qc9m4gbr-model-val…
tomsmith8 Apr 28, 2026
ba0a9d5
Merge branch 'master' into bugfix/cmo9ffuvl000bjq04qc9m4gbr-model-val…
tomsmith8 Apr 28, 2026
afa5015
Merge branch 'master' into bugfix/cmo9ffuvl000bjq04qc9m4gbr-model-val…
tomsmith8 Apr 29, 2026
aa40825
Merge branch 'master' into bugfix/cmo9ffuvl000bjq04qc9m4gbr-model-val…
tomsmith8 Apr 29, 2026
695bb0e
Merge branch 'master' into bugfix/cmo9ffuvl000bjq04qc9m4gbr-model-val…
tomsmith8 Apr 29, 2026
8607621
Merge branch 'master' into bugfix/cmo9ffuvl000bjq04qc9m4gbr-model-val…
tomsmith8 Apr 29, 2026
cf71de5
Merge branch 'master' into bugfix/cmo9ffuvl000bjq04qc9m4gbr-model-val…
tomsmith8 Apr 29, 2026
292e040
Merge branch 'master' into bugfix/cmo9ffuvl000bjq04qc9m4gbr-model-val…
tomsmith8 Apr 29, 2026
63801ff
Merge branch 'master' into bugfix/cmo9ffuvl000bjq04qc9m4gbr-model-val…
tomsmith8 Apr 29, 2026
0bc4d56
Merge branch 'master' into bugfix/cmo9ffuvl000bjq04qc9m4gbr-model-val…
tomsmith8 Apr 29, 2026
aef5802
Merge branch 'master' into bugfix/cmo9ffuvl000bjq04qc9m4gbr-model-val…
tomsmith8 Apr 29, 2026
9aa39b1
Merge branch 'master' into bugfix/cmo9ffuvl000bjq04qc9m4gbr-model-val…
tomsmith8 Apr 30, 2026
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
42 changes: 42 additions & 0 deletions src/__tests__/integration/api/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,48 @@ describe("POST /api/agent Integration Tests", () => {
expect(sessionBody.agent_name).toBeUndefined();
expect(sessionBody.model).toBe("sonnet");
});

test("passes full provider/name model string through to session payload (openrouter)", async () => {
const user = await createTestUser();
const workspace = await createTestWorkspace({ ownerId: user.id });
const task = await createTestTask({
workspaceId: workspace.id,
createdById: user.id,
title: "Kimi test task",
});

await db.task.update({
where: { id: task.id },
data: { mode: "agent", model: "openrouter/moonshotai/kimi-k2.6" },
});

process.env.OPENROUTER_API_KEY = "test-openrouter-key";

getMockedSession().mockResolvedValue(createAuthenticatedSession(user));

const request = createPostRequest("http://localhost/api/agent", {
message: "Help me write a feature",
taskId: task.id,
model: "openrouter/moonshotai/kimi-k2.6",
});

const response = await POST(request);
const data = await response.json();

expect(response.status).toBe(200);
expect(data.success).toBe(true);

const sessionCall = mockFetch.mock.calls.find(([url]: [string]) =>
url.includes("/session"),
);
expect(sessionCall).toBeDefined();

const sessionBody = JSON.parse(sessionCall[1].body);
expect(sessionBody.model).toBe("openrouter/moonshotai/kimi-k2.6");
expect(sessionBody.apiKey).toBe("test-openrouter-key");

delete process.env.OPENROUTER_API_KEY;
});
});

// NOTE: Most tests commented out due to significant implementation gaps:
Expand Down
19 changes: 2 additions & 17 deletions src/__tests__/integration/api/pool-manager/create-pool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ describe("POST /api/pool-manager/create-pool", () => {
expect(data.pool).toEqual(mockPool);
});

test("allows workspace member to create pool", async () => {
test("denies workspace member (DEVELOPER role) from creating pool — requires ADMIN or OWNER", async () => {
const member = await createTestUser({ email: "member@test.com" });
await db.workspaceMember.create({
data: {
Expand All @@ -225,20 +225,6 @@ describe("POST /api/pool-manager/create-pool", () => {

getMockedSession().mockResolvedValue(createAuthenticatedSession(member));

const mockPool = {
id: "pool-456",
name: swarm.id,
status: "active" as const,
owner_id: member.id,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};

mockPoolManagerService.mockReturnValue({
createPool: vi.fn().mockResolvedValue(mockPool),
updateApiKey: vi.fn(),
} as any);

const request = createPostRequest(
"http://localhost/api/pool-manager/create-pool",
{
Expand All @@ -248,8 +234,7 @@ describe("POST /api/pool-manager/create-pool", () => {
);
const response = await POST(request);

const data = await expectSuccess(response, 201);
expect(data.pool).toEqual(mockPool);
await expectNotFound(response, "Workspace not found or access denied");
});
});

Expand Down
26 changes: 24 additions & 2 deletions src/__tests__/unit/lib/ai/models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,30 @@ describe("models", () => {
expect(isValidModel("haiku")).toBe(true);
});

test("returns false for unknown model", () => {
expect(isValidModel("unknown-model")).toBe(false);
test("returns true for full provider/name format strings", () => {
expect(isValidModel("openrouter/moonshotai/kimi-k2.6")).toBe(true);
expect(isValidModel("anthropic/claude-sonnet-4-6")).toBe(true);
expect(isValidModel("openai/gpt-4o")).toBe(true);
expect(isValidModel("google/gemini-pro")).toBe(true);
});

test("returns true for legacy short aliases", () => {
expect(isValidModel("sonnet")).toBe(true);
expect(isValidModel("gpt")).toBe(true);
expect(isValidModel("gemini")).toBe(true);
});

test("returns true for any non-empty string (previously unknown model)", () => {
// isValidModel now accepts any non-empty string; the admin panel is source of truth
expect(isValidModel("unknown-model")).toBe(true);
});

test("returns false for empty string", () => {
expect(isValidModel("")).toBe(false);
});

test("returns false for whitespace-only string", () => {
expect(isValidModel(" ")).toBe(false);
});

test("returns false for non-string values", () => {
Expand Down
113 changes: 84 additions & 29 deletions src/__tests__/unit/pages/task-reconciliation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,25 +98,43 @@ vi.mock("@/components/ui/resizable", () => {
return {
ResizablePanel: ({ children }: any) => React.createElement("div", null, children),
ResizablePanelGroup: ({ children }: any) => React.createElement("div", null, children),
ResizableHandle: () => React.createElement("div", null),
ResizableHandle: () => React.createElement("div"),
};
});

vi.mock("framer-motion", () => {
const React = require("react");
return {
motion: {
div: ({ children, ...props }: any) => React.createElement("div", props, children),
},
AnimatePresence: ({ children }: any) =>
React.createElement(React.Fragment, null, children),
};
});
vi.mock("@/hooks/useWorkspaceAccess", () => ({
useWorkspaceAccess: () => ({
canRead: true,
canWrite: true,
canAdmin: false,
permissions: {},
}),
}));

vi.mock("@/contexts/StreamContext", () => ({
useStreamContext: () => ({
streamContext: null,
onMessage: vi.fn(),
onWorkflowStatusUpdate: vi.fn(),
}),
}));

vi.mock("sonner", () => ({
toast: { error: vi.fn(), success: vi.fn() },
}));

vi.mock("sonner", () => ({ toast: { error: vi.fn(), success: vi.fn() } }));
vi.mock("framer-motion", () => ({
motion: {
div: ({ children, ...props }: any) => {
const React = require("react");
return React.createElement("div", props, children);
},
},
AnimatePresence: ({ children }: any) => children,
}));

// ---------------------------------------------------------------------------
// useWorkflowPolling mock — controllable per-test
// Workflow polling mock — tests can set mockWorkflowPollingData to simulate results
// ---------------------------------------------------------------------------
let mockWorkflowPollingData: any = null;
vi.mock("@/hooks/useWorkflowPolling", () => ({
Expand Down Expand Up @@ -167,16 +185,49 @@ function makeMessagesResponse(overrides: {
describe("TaskChatPage — reconciliation polling", () => {
const mockFetch = vi.fn();

// Per-test URL-keyed response queues. Fetch calls are routed by URL substring.
// More specific keys should be pushed first so they match before shorter keys.
const urlQueues: Map<string, Array<{ ok: boolean; json: () => Promise<unknown> }>> = new Map();

function pushFetchResponse(
urlSubstring: string,
response: { ok: boolean; json: () => Promise<unknown> },
) {
if (!urlQueues.has(urlSubstring)) urlQueues.set(urlSubstring, []);
urlQueues.get(urlSubstring)!.push(response);
}

beforeEach(() => {
vi.clearAllMocks();
capturedOnWorkflowStatusUpdate = null;
mockWorkflowPollingData = null;
urlQueues.clear();

// Route fetch calls by URL substring. This avoids FIFO queue conflicts between
// the /api/llm-models fetch (added for the LLM model selector) and the task
// messages fetch — both fire on mount.
mockFetch.mockImplementation((url: string) => {
if (typeof url === "string") {
for (const [key, queue] of urlQueues.entries()) {
if (url.includes(key) && queue.length > 0) {
return Promise.resolve(queue.shift()!);
}
}
// Fallback by URL pattern
if (url.includes("/api/llm-models")) {
return Promise.resolve({ ok: true, json: async () => ({ models: [] }) });
}
}
return Promise.resolve({ ok: true, json: async () => ({}) });
});

global.fetch = mockFetch;
});

it("starts reconciliation when task loads with IN_PROGRESS + stakworkProjectId", async () => {
mockFetch.mockResolvedValue(
makeMessagesResponse({ workflowStatus: "IN_PROGRESS", stakworkProjectId: 42 })
pushFetchResponse(
"/api/tasks/task-abc/messages",
makeMessagesResponse({ workflowStatus: "IN_PROGRESS", stakworkProjectId: 42 }),
);

const { useWorkflowPolling } = await import("@/hooks/useWorkflowPolling");
Expand Down Expand Up @@ -204,8 +255,9 @@ describe("TaskChatPage — reconciliation polling", () => {
});

it("does not start reconciliation when task loads with COMPLETED status", async () => {
mockFetch.mockResolvedValue(
makeMessagesResponse({ workflowStatus: "COMPLETED", stakworkProjectId: 42 })
pushFetchResponse(
"/api/tasks/task-abc/messages",
makeMessagesResponse({ workflowStatus: "COMPLETED", stakworkProjectId: 42 }),
);

const { useWorkflowPolling } = await import("@/hooks/useWorkflowPolling");
Expand Down Expand Up @@ -233,8 +285,9 @@ describe("TaskChatPage — reconciliation polling", () => {
});

it("does not start reconciliation when IN_PROGRESS but no stakworkProjectId", async () => {
mockFetch.mockResolvedValue(
makeMessagesResponse({ workflowStatus: "IN_PROGRESS", stakworkProjectId: null })
pushFetchResponse(
"/api/tasks/task-abc/messages",
makeMessagesResponse({ workflowStatus: "IN_PROGRESS", stakworkProjectId: null }),
);

const { useWorkflowPolling } = await import("@/hooks/useWorkflowPolling");
Expand Down Expand Up @@ -262,12 +315,12 @@ describe("TaskChatPage — reconciliation polling", () => {
});

it("patches workflowStatus to COMPLETED and stops reconciling when polling returns 'completed'", async () => {
// Page loads with IN_PROGRESS
mockFetch.mockResolvedValueOnce(
makeMessagesResponse({ workflowStatus: "IN_PROGRESS", stakworkProjectId: 42 })
// Push more-specific URL first so messages fetch matches before the PATCH URL key
pushFetchResponse(
"/api/tasks/task-abc/messages",
makeMessagesResponse({ workflowStatus: "IN_PROGRESS", stakworkProjectId: 42 }),
);
// PATCH call
mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({}) });
pushFetchResponse("/api/tasks/task-abc", { ok: true, json: async () => ({}) });

// Simulate polling returning completed
mockWorkflowPollingData = {
Expand Down Expand Up @@ -304,10 +357,11 @@ describe("TaskChatPage — reconciliation polling", () => {
});

it("patches workflowStatus to FAILED and stops reconciling when polling returns 'failed'", async () => {
mockFetch.mockResolvedValueOnce(
makeMessagesResponse({ workflowStatus: "IN_PROGRESS", stakworkProjectId: 99 })
pushFetchResponse(
"/api/tasks/task-abc/messages",
makeMessagesResponse({ workflowStatus: "IN_PROGRESS", stakworkProjectId: 99 }),
);
mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({}) });
pushFetchResponse("/api/tasks/task-abc", { ok: true, json: async () => ({}) });

mockWorkflowPollingData = {
status: "failed",
Expand Down Expand Up @@ -335,8 +389,9 @@ describe("TaskChatPage — reconciliation polling", () => {

it("stops reconciliation when Pusher WORKFLOW_STATUS_UPDATE fires, with no extra PATCH", async () => {
// Task loads with IN_PROGRESS — reconciliation starts
mockFetch.mockResolvedValueOnce(
makeMessagesResponse({ workflowStatus: "IN_PROGRESS", stakworkProjectId: 77 })
pushFetchResponse(
"/api/tasks/task-abc/messages",
makeMessagesResponse({ workflowStatus: "IN_PROGRESS", stakworkProjectId: 77 }),
);

// No terminal polling data — reconciliation is active but hasn't resolved yet
Expand Down
10 changes: 5 additions & 5 deletions src/app/api/agent/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ import { db } from "@/lib/db";
import { EncryptionService } from "@/lib/encryption";
import { ChatRole, ChatStatus, ArtifactType } from "@prisma/client";
import { createWebhookToken, generateWebhookSecret } from "@/lib/auth/agent-jwt";
import { isValidModel, getApiKeyForModel, type ModelName } from "@/lib/ai/models";
import { isValidModel, getApiKeyForModel } from "@/lib/ai/models";
import { canAccessServerFeature, FEATURE_FLAGS } from "@/lib/feature-flags";
import { claimPodAndGetFrontend, updatePodRepositories, POD_PORTS, releasePodById } from "@/lib/pods";

Expand Down Expand Up @@ -325,7 +325,7 @@ async function createAgentSession(
agentPassword: string | null,
taskId: string,
webhookUrl: string,
effectiveModel: ModelName | undefined,
effectiveModel: string | undefined,
): Promise<string> {
const sessionUrl = agentUrl.replace(/\/$/, "") + "/session";

Expand Down Expand Up @@ -409,7 +409,7 @@ export async function POST(request: NextRequest) {
const { message, taskId, artifacts = [], model } = body;

// Validate model parameter if provided
const requestModel: ModelName | undefined = isValidModel(model) ? model : undefined;
const requestModel: string | undefined = isValidModel(model) ? model : undefined;

// 1. Authenticate user
const session = await getServerSession(authOptions);
Expand Down Expand Up @@ -475,8 +475,8 @@ export async function POST(request: NextRequest) {
}

// Determine effective model: request > task > default
const taskModel: ModelName | undefined = isValidModel(task.model) ? task.model : undefined;
const effectiveModel: ModelName | undefined = requestModel || taskModel;
const taskModel: string | undefined = isValidModel(task.model) ? task.model : undefined;
const effectiveModel: string | undefined = requestModel || taskModel;

// 3. Ensure pod is available (claim if needed)
let agentCredentials: AgentCredentials;
Expand Down
24 changes: 10 additions & 14 deletions src/app/api/pool-manager/create-pool/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { EncryptionService } from "@/lib/encryption";
import { poolManagerService } from "@/lib/service-factory";
import { saveOrUpdateSwarm } from "@/services/swarm/db";
import { getSwarmPoolApiKeyFor, updateSwarmPoolApiKeyFor } from "@/services/swarm/secrets";
import { validateWorkspaceAccessById } from "@/services/workspace";
import { isApiError } from "@/types/errors";
import { getServerSession } from "next-auth/next";
import { NextRequest, NextResponse } from "next/server";
Expand Down Expand Up @@ -88,7 +89,9 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Invalid user session" }, { status: 401 });
}

// Find the swarm and verify user has access to the workspace
// Find the swarm to resolve the canonical workspaceId (never trust
// the body-supplied workspaceId alone — an attacker could pass their
// own workspaceId with a victim's swarmId to pass the auth check).
const swarm = await db.swarm.findFirst({
where: {
...(swarmId ? { swarmId } : {}),
Expand All @@ -99,11 +102,6 @@ export async function POST(request: NextRequest) {
select: {
id: true,
slug: true,
ownerId: true,
members: {
where: { userId, leftAt: null },
select: { role: true },
},
},
},
},
Expand All @@ -113,21 +111,19 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Swarm not found" }, { status: 404 });
}

// IDOR guard: previously the owner/member check ran further down,
// AFTER the handler had already called `saveOrUpdateSwarm({ containerFiles })`
// with attacker-controlled content. Run the authz check immediately
// after the swarm lookup so no swarm row or secret decryption work
// happens on behalf of a non-member.
if (!swarm.workspace) {
return NextResponse.json(
{ error: "Workspace not found or access denied" },
{ status: 404 },
);
}

const isOwner = swarm.workspace.ownerId === userId;
const isMember = swarm.workspace.members.length > 0;
if (!isOwner && !isMember) {
// IDOR + privilege guard: pool creation is an infrastructure-level
// operation (equivalent to delete). Require ADMIN or OWNER on the
// swarm's actual workspace — any lesser role (VIEWER, DEVELOPER, etc.)
// must not be able to provision compute resources.
const access = await validateWorkspaceAccessById(swarm.workspaceId, userId);
if (!access.hasAccess || !access.canAdmin) {
return NextResponse.json(
{ error: "Workspace not found or access denied" },
{ status: 404 },
Expand Down
Loading
Loading