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,26 @@
import { NextRequest } from "next/server";
import {
authenticateCoderPanel,
relayJson,
workspaceFetch,
} from "@/lib/workspace/proxy";

export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ cid: string }> }
) {
const gate = await authenticateCoderPanel();
if (!gate.ok) return gate.response;
const { cid } = await params;
const sessionId = req.nextUrl.searchParams.get("session") ?? undefined;
const upstream = await workspaceFetch(
gate.userId,
`/internal/coder/conversations/${encodeURIComponent(cid)}/cancel`,
{
sessionId,
method: "POST",
headers: { "content-type": "application/json" },
},
);
return relayJson(upstream);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { NextRequest } from "next/server";
import {
authenticateCoderPanel,
relayJson,
workspaceFetch,
} from "@/lib/workspace/proxy";

/** Re-attach to a running turn: replay from a seq, then stream live. */
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ cid: string }> }
) {
const gate = await authenticateCoderPanel();
if (!gate.ok) return gate.response;
const { cid } = await params;
const sessionId = req.nextUrl.searchParams.get("session") ?? undefined;
const turnId = req.nextUrl.searchParams.get("turnId") ?? "";
const fromSeq = req.nextUrl.searchParams.get("from_seq") ?? "0";
const upstream = await workspaceFetch(
gate.userId,
`/internal/coder/conversations/${encodeURIComponent(cid)}/follow?turnId=${encodeURIComponent(turnId)}&from_seq=${encodeURIComponent(fromSeq)}`,
{ sessionId },
);
if (!upstream.ok || !upstream.body) return relayJson(upstream);
return new Response(upstream.body, {
status: 200,
headers: {
"content-type": "application/x-ndjson; charset=utf-8",
"x-content-type-options": "nosniff",
"cache-control": "no-cache, no-transform",
},
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { NextRequest } from "next/server";
import {
authenticateCoderPanel,
relayJson,
workspaceFetch,
} from "@/lib/workspace/proxy";

/** Re-open (or confirm alive) an existing conversation — redeems a fresh ticket. */
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ cid: string }> }
) {
const gate = await authenticateCoderPanel();
if (!gate.ok) return gate.response;
const { cid } = await params;
const sessionId = req.nextUrl.searchParams.get("session") ?? undefined;
const body = await req.text();
const upstream = await workspaceFetch(
gate.userId,
`/internal/coder/conversations/${encodeURIComponent(cid)}/open`,
{
sessionId,
method: "POST",
body: body || "{}",
headers: { "content-type": "application/json" },
},
);
return relayJson(upstream);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { NextRequest } from "next/server";
import {
authenticateCoderPanel,
relayJson,
workspaceFetch,
} from "@/lib/workspace/proxy";

/** Resolve a pending approval/question request back onto the agent's wire. */
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ cid: string }> }
) {
const gate = await authenticateCoderPanel();
if (!gate.ok) return gate.response;
const { cid } = await params;
const sessionId = req.nextUrl.searchParams.get("session") ?? undefined;
const body = await req.text();
const upstream = await workspaceFetch(
gate.userId,
`/internal/coder/conversations/${encodeURIComponent(cid)}/respond`,
{
sessionId,
method: "POST",
body: body || "{}",
headers: { "content-type": "application/json" },
},
);
return relayJson(upstream);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { NextRequest } from "next/server";
import {
authenticateCoderPanel,
relayJson,
workspaceFetch,
} from "@/lib/workspace/proxy";

/**
* Relay one coder turn as a stream. coderd answers with newline-delimited
* JSON (an event per line, ending in a turn-end marker); we pass the body
* straight through so the browser renders the turn as it arrives. Non-2xx
* (409 busy / not_started) comes back as a normal JSON error instead.
*/
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ cid: string }> }
) {
const gate = await authenticateCoderPanel();
if (!gate.ok) return gate.response;
const { cid } = await params;
const sessionId = req.nextUrl.searchParams.get("session") ?? undefined;
const body = await req.text();
const upstream = await workspaceFetch(
gate.userId,
`/internal/coder/conversations/${encodeURIComponent(cid)}/send`,
{
sessionId,
method: "POST",
body: body || "{}",
headers: { "content-type": "application/json" },
},
);
if (!upstream.ok || !upstream.body) return relayJson(upstream);
return new Response(upstream.body, {
status: 200,
headers: {
"content-type": "application/x-ndjson; charset=utf-8",
"x-content-type-options": "nosniff",
"cache-control": "no-cache, no-transform",
},
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { NextRequest } from "next/server";
import {
authenticateCoderPanel,
relayJson,
workspaceFetch,
} from "@/lib/workspace/proxy";

/** Drop the conversation — the next open respawns fresh (fresh auth). */
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ cid: string }> }
) {
const gate = await authenticateCoderPanel();
if (!gate.ok) return gate.response;
const { cid } = await params;
const sessionId = req.nextUrl.searchParams.get("session") ?? undefined;
const upstream = await workspaceFetch(
gate.userId,
`/internal/coder/conversations/${encodeURIComponent(cid)}/stop`,
{
sessionId,
method: "POST",
},
);
return relayJson(upstream);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { NextRequest } from "next/server";
import {
authenticateCoderPanel,
relayJson,
workspaceFetch,
} from "@/lib/workspace/proxy";

/** Last turn's state, plus any pending approval/question requests. */
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ cid: string }> }
) {
const gate = await authenticateCoderPanel();
if (!gate.ok) return gate.response;
const { cid } = await params;
const sessionId = req.nextUrl.searchParams.get("session") ?? undefined;
const upstream = await workspaceFetch(
gate.userId,
`/internal/coder/conversations/${encodeURIComponent(cid)}/turn`,
{ sessionId },
);
return relayJson(upstream);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { NextRequest } from "next/server";
import {
authenticateCoderPanel,
relayJson,
workspaceFetch,
} from "@/lib/workspace/proxy";

/** List live conversations for this workspace. */
export async function GET(req: NextRequest) {
const gate = await authenticateCoderPanel();
if (!gate.ok) return gate.response;
const sessionId = req.nextUrl.searchParams.get("session") ?? undefined;
const upstream = await workspaceFetch(gate.userId, "/internal/coder/conversations", {
sessionId,
});
return relayJson(upstream);
}

/** Create a new conversation — redeems a one-time ticket, spawns the agent. */
export async function POST(req: NextRequest) {
const gate = await authenticateCoderPanel();
if (!gate.ok) return gate.response;
const sessionId = req.nextUrl.searchParams.get("session") ?? undefined;
const body = await req.text();
const upstream = await workspaceFetch(
gate.userId,
"/internal/coder/conversations",
{
sessionId,
method: "POST",
body: body || "{}",
headers: { "content-type": "application/json" },
},
);
return relayJson(upstream);
}
36 changes: 36 additions & 0 deletions control-plane/artifacts/sanad-web/lib/workspace/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { auth, currentUser } from "@clerk/nextjs/server";
import { NextResponse } from "next/server";
import { err } from "../http/envelope";
import { isTerminalAllowed } from "../auth/terminal";
import { isCoderPanelAllowed } from "../auth/coder";

export type WorkspaceAuth = { ok: true; userId: string } | { ok: false; response: NextResponse };

Expand All @@ -34,6 +35,41 @@ export async function authenticateWorkspace(): Promise<WorkspaceAuth> {
return { ok: true, userId };
}

/**
* Coder-panel gate: workspace access (Clerk + SANAD_TERMINAL_EMAILS) plus the
* stricter SANAD_CODER_PANEL_EMAILS allowlist — write-capable agent access is
* grantable to a subset of workspace users. Both fail closed.
*/
export async function authenticateCoderPanel(): Promise<WorkspaceAuth> {
const { userId } = await auth();
if (!userId) {
return { ok: false, response: err(401, "unauthorized", "Must be signed in") };
}
const clerkUser = await currentUser();
const email = clerkUser?.emailAddresses[0]?.emailAddress ?? "";
if (!isTerminalAllowed(email)) {
return {
ok: false,
response: err(
403,
"terminal_not_enabled",
"The web workspace is not enabled for this account"
),
};
}
if (!isCoderPanelAllowed(email)) {
return {
ok: false,
response: err(
403,
"coder_not_enabled",
"The coding agent is not enabled for this account"
),
};
}
return { ok: true, userId };
}

/**
* Forward a request to the user's workspace, injecting service auth.
*
Expand Down
63 changes: 63 additions & 0 deletions control-plane/artifacts/sanad-web/tests/unit/coder-gate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { describe, it, expect, afterEach, vi } from "vitest";

const auth = vi.fn();
const currentUser = vi.fn();
vi.mock("@clerk/nextjs/server", () => ({
auth: (...a: unknown[]) => auth(...a),
currentUser: (...a: unknown[]) => currentUser(...a),
}));

import { authenticateCoderPanel } from "@/lib/workspace/proxy";

describe("authenticateCoderPanel", () => {
const origTerm = process.env.SANAD_TERMINAL_EMAILS;
const origCoder = process.env.SANAD_CODER_PANEL_EMAILS;
afterEach(() => {
process.env.SANAD_TERMINAL_EMAILS = origTerm ?? "";
process.env.SANAD_CODER_PANEL_EMAILS = origCoder ?? "";
if (origTerm === undefined) delete process.env.SANAD_TERMINAL_EMAILS;
if (origCoder === undefined) delete process.env.SANAD_CODER_PANEL_EMAILS;
vi.clearAllMocks();
});

it("401 when not signed in", async () => {
auth.mockResolvedValue({ userId: null });
const gate = await authenticateCoderPanel();
expect(gate.ok).toBe(false);
if (!gate.ok) expect(gate.response.status).toBe(401);
});

it("403 terminal_not_enabled when workspace access is missing", async () => {
auth.mockResolvedValue({ userId: "u1" });
currentUser.mockResolvedValue({ emailAddresses: [{ emailAddress: "x@y.z" }] });
process.env.SANAD_TERMINAL_EMAILS = "";
process.env.SANAD_CODER_PANEL_EMAILS = "x@y.z";
const gate = await authenticateCoderPanel();
expect(gate.ok).toBe(false);
if (!gate.ok) expect(gate.response.status).toBe(403);
});

it("403 coder_not_enabled when only the coder allowlist is missing", async () => {
auth.mockResolvedValue({ userId: "u1" });
currentUser.mockResolvedValue({ emailAddresses: [{ emailAddress: "x@y.z" }] });
process.env.SANAD_TERMINAL_EMAILS = "x@y.z";
process.env.SANAD_CODER_PANEL_EMAILS = "";
const gate = await authenticateCoderPanel();
expect(gate.ok).toBe(false);
if (!gate.ok) {
expect(gate.response.status).toBe(403);
const body = await gate.response.json();
expect(body.error.code).toBe("coder_not_enabled");
}
});

it("ok with both allowlists", async () => {
auth.mockResolvedValue({ userId: "u1" });
currentUser.mockResolvedValue({ emailAddresses: [{ emailAddress: "x@y.z" }] });
process.env.SANAD_TERMINAL_EMAILS = "x@y.z";
process.env.SANAD_CODER_PANEL_EMAILS = "x@y.z";
const gate = await authenticateCoderPanel();
expect(gate.ok).toBe(true);
if (gate.ok) expect(gate.userId).toBe("u1");
});
});
Loading
Loading