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
129 changes: 129 additions & 0 deletions src/core/__tests__/trigger-auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import YAML from "yaml";
import { hashTokenSync } from "../../mcp/config.ts";
import type { McpConfig } from "../../mcp/types.ts";
import { setTriggerDeps, startServer } from "../server.ts";

/**
* Tests that the /trigger endpoint requires bearer token auth
* with operator scope. Closes ghostwright/phantom#9.
*/
describe("/trigger endpoint auth", () => {
const adminToken = "test-trigger-admin-token";
const readToken = "test-trigger-read-token";
const operatorToken = "test-trigger-operator-token";

const mcpConfigPath = "config/mcp.yaml";
let originalMcpYaml: string | null = null;
let server: ReturnType<typeof Bun.serve>;
let baseUrl: string;

beforeAll(() => {
// Back up the existing mcp.yaml so we can restore it after tests
if (existsSync(mcpConfigPath)) {
originalMcpYaml = readFileSync(mcpConfigPath, "utf-8");
}

// Write test tokens to mcp.yaml so loadMcpConfig picks them up
const mcpConfig: McpConfig = {
tokens: [
{ name: "admin", hash: hashTokenSync(adminToken), scopes: ["read", "operator", "admin"] },
{ name: "reader", hash: hashTokenSync(readToken), scopes: ["read"] },
{ name: "operator", hash: hashTokenSync(operatorToken), scopes: ["read", "operator"] },
],
rate_limit: { requests_per_minute: 60, burst: 10 },
};

mkdirSync("config", { recursive: true });
writeFileSync(mcpConfigPath, YAML.stringify(mcpConfig), "utf-8");

// Start server with a random port
server = startServer({ name: "test", port: 0, role: "base" } as never, Date.now());
baseUrl = `http://localhost:${server.port}`;

// Wire trigger deps with a mock runtime
setTriggerDeps({
runtime: {
handleMessage: async () => ({
text: "ok",
cost: { totalUsd: 0 },
durationMs: 0,
}),
} as never,
});
});

afterAll(() => {
server?.stop(true);
// Restore the original mcp.yaml
if (originalMcpYaml !== null) {
writeFileSync(mcpConfigPath, originalMcpYaml, "utf-8");
}
});

const triggerBody = JSON.stringify({ task: "hello" });

test("rejects request with no Authorization header", async () => {
const res = await fetch(`${baseUrl}/trigger`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: triggerBody,
});
expect(res.status).toBe(401);
const json = (await res.json()) as { status: string; message: string };
expect(json.message).toContain("Missing");
});

test("rejects request with invalid token", async () => {
const res = await fetch(`${baseUrl}/trigger`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer wrong-token",
},
body: triggerBody,
});
expect(res.status).toBe(401);
});

test("rejects read-only token (insufficient scope)", async () => {
const res = await fetch(`${baseUrl}/trigger`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${readToken}`,
},
body: triggerBody,
});
expect(res.status).toBe(403);
const json = (await res.json()) as { status: string; message: string };
expect(json.message).toContain("operator");
});

test("accepts operator token", async () => {
const res = await fetch(`${baseUrl}/trigger`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${operatorToken}`,
},
body: triggerBody,
});
expect(res.status).toBe(200);
const json = (await res.json()) as { status: string };
expect(json.status).toBe("ok");
});

test("accepts admin token", async () => {
const res = await fetch(`${baseUrl}/trigger`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${adminToken}`,
},
body: triggerBody,
});
expect(res.status).toBe(200);
});
});
20 changes: 20 additions & 0 deletions src/core/server.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { AgentRuntime } from "../agent/runtime.ts";
import type { SlackChannel } from "../channels/slack.ts";
import type { PhantomConfig } from "../config/types.ts";
import { AuthMiddleware } from "../mcp/auth.ts";
import { loadMcpConfig } from "../mcp/config.ts";
import type { PhantomMcpServer } from "../mcp/server.ts";
import type { MemoryHealth } from "../memory/types.ts";
import { handleUiRequest } from "../ui/serve.ts";
Expand Down Expand Up @@ -67,7 +69,12 @@ export function setTriggerDeps(deps: TriggerDeps): void {
triggerDeps = deps;
}

let triggerAuth: AuthMiddleware | null = null;

export function startServer(config: PhantomConfig, startedAt: number): ReturnType<typeof Bun.serve> {
const mcpConfig = loadMcpConfig();
triggerAuth = new AuthMiddleware(mcpConfig);

const server = Bun.serve({
port: config.port,
async fetch(req) {
Expand Down Expand Up @@ -142,6 +149,19 @@ export function startServer(config: PhantomConfig, startedAt: number): ReturnTyp
}

async function handleTrigger(req: Request): Promise<Response> {
if (!triggerAuth) {
return Response.json({ status: "error", message: "Auth not initialized" }, { status: 503 });
}

const auth = await triggerAuth.authenticate(req);
if (!auth.authenticated) {
return Response.json({ status: "error", message: auth.error }, { status: 401 });
}

if (!triggerAuth.hasScope(auth, "operator")) {
return Response.json({ status: "error", message: "Insufficient scope: operator required" }, { status: 403 });
}

if (!triggerDeps) {
return Response.json({ status: "error", message: "Trigger not configured" }, { status: 503 });
}
Expand Down
Loading