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
15 changes: 15 additions & 0 deletions internal/api/src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,21 @@ function readAuthTokenFromRequest(

export const withAuth = createAuthMiddleware();

export const withDevhookAuth: MiddlewareHandler<{
Bindings: Bindings;
Variables: {
user_id?: string;
api_key?: ApiKey;
auth_type?: "session" | "api_key";
};
}> = async (c, next) => {
if (c.env.devhook?.disableAuth) {
await next();
return;
}
return withAuth(c as Parameters<typeof withAuth>[0], next);
};

export const withOrganizationIDQueryParam: MiddlewareHandler<
{
Bindings: Bindings;
Expand Down
11 changes: 9 additions & 2 deletions internal/api/src/routes/devhook.server.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { validate } from "uuid";
import { withDevhookAuth } from "../middleware";
import type { APIServer } from "../server";
import { createWebhookURL } from "../server-helper";

export default function mountDevhook(server: APIServer) {
server.get("/:devhook/url", async (c) => {
// this endpoint is used by packages/server/src/server.ts
// to authorize the listen request. it must use the exact same auth
// method as the one required to listen on the matching devhook URL.
server.get("/:devhook/url", withDevhookAuth, async (c) => {
const id = c.req.param("devhook");
if (!validate(id)) {
return c.json({ message: "Invalid devhook ID" }, 400);
Expand All @@ -20,7 +24,10 @@ export default function mountDevhook(server: APIServer) {
return c.json({ url });
});

server.get("/:devhook", async (c) => {
// this endpoint is somewhat misleading. in self-hosted mode,
// it's not used during the flow to listen on the devhook URL.
// websocket upgrade logic is handled in packages/server/src/server.ts
server.get("/:devhook", withDevhookAuth, async (c) => {
const id = c.req.param("devhook");
if (!validate(id)) {
return c.json({ message: "Invalid devhook ID" }, 400);
Expand Down
48 changes: 39 additions & 9 deletions internal/api/src/routes/devhook.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,43 @@ test("devhook url with path-based routing", async () => {
expect(url).toBe(expectedUrl.toString());
});

test("devhook", async () => {
const { helpers, bindings, url } = await serve();
// This endpoint doesn't actually need auth, we just
// create a user to easily get a client.
test("devhook routes require auth by default", async () => {
const { url } = await serve();
const id = crypto.randomUUID();

const urlResponse = await fetch(new URL(`/api/devhook/${id}/url`, url));
expect(urlResponse.status).toBe(401);

const listenResponse = await fetch(new URL(`/api/devhook/${id}`, url));
expect(listenResponse.status).toBe(401);
});

test("devhook routes allow unauthenticated access when disableAuth is true", async () => {
const { url, bindings } = await serve({
bindings: { devhook: { disableAuth: true } },
});
const id = crypto.randomUUID();

const urlResponse = await fetch(new URL(`/api/devhook/${id}/url`, url));
expect(urlResponse.status).toBe(200);
const data = await urlResponse.json();
expect(data.url).toBe(
bindings.createRequestURL!(id).toString().replace(/\/$/, "")
);

// Listen endpoint tries to upgrade to WebSocket, so without proper headers
// it won't succeed, but it should not return 401
const listenResponse = await fetch(new URL(`/api/devhook/${id}`, url));
expect(listenResponse.status).not.toBe(401);
});

test.each([
{ disableAuth: true, name: "without auth" },
{ disableAuth: false, name: "with auth" },
])("devhook listen $name", async ({ disableAuth }) => {
const { helpers, bindings, url } = await serve({
bindings: { devhook: { disableAuth } },
});
const { client } = await helpers.createUser();

const id = crypto.randomUUID();
Expand All @@ -44,12 +77,9 @@ test("devhook", async () => {
let requestReceived = false;
client.devhook.listen({
id,
onError: (err) => {
console.error("Error", err);
},
onRequest: async (req) => {
onError: () => {},
onRequest: async () => {
requestReceived = true;
// Verify the request URL is correct.
return new Response("Hello from devhook!");
},
onConnect: () => {
Expand Down
1 change: 1 addition & 0 deletions internal/api/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ export interface Bindings {
readonly devhook?: {
readonly handleListen: (id: string, req: Request) => Promise<Response>;
readonly handleRequest: (id: string, req: Request) => Promise<Response>;
readonly disableAuth?: boolean;
};
readonly sendEmail?: (email: Email) => Promise<void>;
readonly sendTelemetryEvent?: (event: TelemetryEvent) => Promise<void>;
Expand Down
2 changes: 2 additions & 0 deletions internal/api/src/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export interface PartialBindings
Bindings,
| "auth"
| "chat"
| "devhook"
| "files"
| "logs"
| "traces"
Expand All @@ -24,6 +25,7 @@ export interface PartialBindings
> {
auth?: Partial<Bindings["auth"]>;
chat?: Partial<Bindings["chat"]>;
devhook?: Partial<NonNullable<Bindings["devhook"]>>;
files?: Partial<Bindings["files"]>;
logs?: Partial<Bindings["logs"]>;
traces?: Partial<Bindings["traces"]>;
Expand Down
1 change: 1 addition & 0 deletions internal/worker/src/new-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,7 @@ export default function handleNewAPI(
},
},
devhook: {
disableAuth: true,
handleListen: async (id, req) => {
const ws = env.WORKSPACE.get(
env.WORKSPACE.idFromName(id)
Expand Down
172 changes: 108 additions & 64 deletions packages/server/src/devhook.test.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,13 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { serve } from "@blink.so/api/test";
import { afterAll, describe, expect, test } from "bun:test";
import Client from "@blink.so/api";
import { createDevhookSupport } from "./devhook";
import { serve } from "./test";

describe("devhook integration tests", () => {
let server: Awaited<ReturnType<typeof serve>>;
let devhookSupport: ReturnType<typeof createDevhookSupport>;
describe("devhook integration tests", async () => {
const server = await serve();

beforeAll(async () => {
server = await serve();

// Create devhook support with the test server's database
const querier = await server.bindings.database();
devhookSupport = createDevhookSupport({
accessUrl: server.url.toString(),
wildcardAccessUrl: `*.${server.url.host}`,
querier,
});
});

afterAll(() => {
server.stop();
afterAll(async () => {
await server[Symbol.asyncDispose]();
});

describe("UUID validation", () => {
Expand Down Expand Up @@ -51,34 +39,116 @@ describe("devhook integration tests", () => {
});
});

describe("handleRequest", () => {
test("rejects WebSocket upgrade requests before proxying", async () => {
const id = crypto.randomUUID();
describe("createDevhookSupport", async () => {
const devhookSupport = createDevhookSupport({
accessUrl: server.url.toString(),
wildcardAccessUrl: `*.${server.url.host}`,
querier: await server.bindings.database(),
});

// Use the real createDevhookSupport handleRequest
const req = new Request("http://localhost/test", {
headers: {
Upgrade: "websocket",
Connection: "Upgrade",
},
describe("handleRequest", () => {
test("rejects WebSocket upgrade requests before proxying", async () => {
const id = crypto.randomUUID();

// Use the real createDevhookSupport handleRequest
const req = new Request("http://localhost/test", {
headers: {
Upgrade: "websocket",
Connection: "Upgrade",
},
});

const response = await devhookSupport.handleRequest(id, req);

expect(response.status).toBe(501);
const body = await response.json();
expect(body.message).toBe("WebSocket proxying not supported");
});

const response = await devhookSupport.handleRequest(id, req);
test("returns 503 when devhook not connected", async () => {
const id = crypto.randomUUID();
const req = new Request("http://localhost/test");

expect(response.status).toBe(501);
const body = await response.json();
expect(body.message).toBe("WebSocket proxying not supported");
const response = await devhookSupport.handleRequest(id, req);

expect(response.status).toBe(503);
const body = await response.json();
expect(body.message).toBe("Devhook not connected");
});
});

test("returns 503 when devhook not connected", async () => {
const id = crypto.randomUUID();
const req = new Request("http://localhost/test");
describe("matchRequestHost", () => {
test("extracts UUID from wildcard hostname", () => {
const id = crypto.randomUUID();
const host = `${id}.${server.url.host}`;

const response = await devhookSupport.handleRequest(id, req);
const matched = devhookSupport.matchRequestHost?.(host);
expect(matched).toBe(id);
});

test("returns undefined for base host", () => {
const matched = devhookSupport.matchRequestHost?.(server.url.host);
expect(matched).toBeUndefined();
});

expect(response.status).toBe(503);
test("returns undefined for invalid UUID subdomain", () => {
const host = `not-a-uuid.${server.url.host}`;
const matched = devhookSupport.matchRequestHost?.(host);
expect(matched).toBeUndefined();
});

test("returns undefined for unrelated host", () => {
const matched = devhookSupport.matchRequestHost?.("example.com");
expect(matched).toBeUndefined();
});
});
});

describe("authentication", () => {
test("requires auth for devhook listen", async () => {
const id = crypto.randomUUID();
const client = new Client({ baseURL: server.url.toString() });
let connected = false;
let errorEvent: unknown;

const outcome = await new Promise<"error" | "disconnect">(
(resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error("Timed out waiting for devhook auth failure"));
}, 5000);

let disposable: { dispose: () => void } | undefined;
disposable = client.devhook.listen({
id,
onRequest: async () => new Response("ok"),
onConnect: () => {
connected = true;
},
onDisconnect: () => {
clearTimeout(timer);
disposable?.dispose();
resolve("disconnect");
},
onError: (err) => {
errorEvent = err;
clearTimeout(timer);
disposable?.dispose();
resolve("error");
},
});
}
);

expect(outcome).toBe("error");
expect(connected).toBe(false);
expect(errorEvent).toBeDefined();

// Assert the actual auth error message via HTTP since WebSocket errors
// do not expose the handshake response body.
const response = await client.request("GET", `/api/devhook/${id}/url`);
expect(response.status).toBe(401);
const body = await response.json();
expect(body.message).toBe("Devhook not connected");
expect(body.message).toBe("Unauthorized");
});
});

Expand Down Expand Up @@ -159,30 +229,4 @@ describe("devhook integration tests", () => {
expect(receivedBody).toBe('{"test":"data"}');
});
});

describe("matchRequestHost", () => {
test("extracts UUID from wildcard hostname", () => {
const id = crypto.randomUUID();
const host = `${id}.${server.url.host}`;

const matched = devhookSupport.matchRequestHost?.(host);
expect(matched).toBe(id);
});

test("returns undefined for base host", () => {
const matched = devhookSupport.matchRequestHost?.(server.url.host);
expect(matched).toBeUndefined();
});

test("returns undefined for invalid UUID subdomain", () => {
const host = `not-a-uuid.${server.url.host}`;
const matched = devhookSupport.matchRequestHost?.(host);
expect(matched).toBeUndefined();
});

test("returns undefined for unrelated host", () => {
const matched = devhookSupport.matchRequestHost?.("example.com");
expect(matched).toBeUndefined();
});
});
});
Loading