From 54a5db3181cd52c706127a33abfa55ccfe783da9 Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Tue, 22 Sep 2026 17:28:49 +0200 Subject: [PATCH] feat(internal/site): optional headers for the blink api client --- internal/api/src/client.browser.ts | 5 ++- internal/api/src/client.node.ts | 8 ++-- internal/api/src/client.test.ts | 40 +++++++++++++++++++ internal/site/lib/api-client.server.ts | 3 ++ internal/site/lib/api-headers.server.test.ts | 42 ++++++++++++++++++++ internal/site/lib/api-headers.server.ts | 24 +++++++++++ 6 files changed, 118 insertions(+), 4 deletions(-) create mode 100644 internal/api/src/client.test.ts create mode 100644 internal/site/lib/api-headers.server.test.ts create mode 100644 internal/site/lib/api-headers.server.ts diff --git a/internal/api/src/client.browser.ts b/internal/api/src/client.browser.ts index f2003c5d..8e611d83 100644 --- a/internal/api/src/client.browser.ts +++ b/internal/api/src/client.browser.ts @@ -13,6 +13,7 @@ import Users from "./routes/users.client"; export interface ClientOptions { baseURL?: string; authToken?: string; + headers?: Record; fetch?: typeof globalThis.fetch; } @@ -20,6 +21,7 @@ export default class Client { public authToken?: string; protected readonly baseURL: URL; + protected readonly headers: Headers; private readonly fetch?: typeof globalThis.fetch; public readonly admin = { @@ -49,6 +51,7 @@ export default class Client { : "https://blink.coder.com") ); this.fetch = options?.fetch; + this.headers = new Headers(options?.headers); this.authToken = options?.authToken; this.auth = new Auth(this, this.baseURL); } @@ -64,7 +67,7 @@ export default class Client { } ) { const url = new URL(path, this.baseURL); - const headers = new Headers(); + const headers = new Headers(this.headers); if (this.authToken) { headers.set("Authorization", `Bearer ${this.authToken}`); } diff --git a/internal/api/src/client.node.ts b/internal/api/src/client.node.ts index c7fea553..96af8e75 100644 --- a/internal/api/src/client.node.ts +++ b/internal/api/src/client.node.ts @@ -18,12 +18,14 @@ export default class Client extends BrowserClient { public override websocket(path: string): WebSocket { const url = new URL(path, this.baseURL); url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; - const headers: Record = {}; + const headers = new Headers(this.headers); if (this.authToken) { - headers.Authorization = `Bearer ${this.authToken}`; + headers.set("Authorization", `Bearer ${this.authToken}`); } // biome-ignore lint/suspicious/noExplicitAny: types are wrong - return new WebSocket(url.toString(), { headers } as any); + return new WebSocket(url.toString(), { + headers: Object.fromEntries(headers), + } as any); } } diff --git a/internal/api/src/client.test.ts b/internal/api/src/client.test.ts new file mode 100644 index 00000000..46969763 --- /dev/null +++ b/internal/api/src/client.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test"; +import BrowserClient from "./client.browser"; + +describe("client default headers", () => { + test("merges defaults, generated headers, and request overrides without leaking overrides", async () => { + const requests: Headers[] = []; + const defaults = { + "X-Gateway": "secret", + authorization: "default", + "content-type": "text/plain", + }; + const client = new BrowserClient({ + headers: defaults, + authToken: "session", + fetch: (async (_url, init) => { + requests.push(new Headers(init?.headers)); + return new Response(); + }) as typeof fetch, + }); + defaults["X-Gateway"] = "changed"; + await client.request("POST", "/api/test", "{}", { + headers: { + "x-gateway": "override", + AUTHORIZATION: "request", + "CONTENT-TYPE": "custom/type", + }, + }); + await client.request("POST", "/api/test", "{}"); + expect(Object.fromEntries(requests[0]!)).toEqual({ + "x-gateway": "override", + authorization: "request", + "content-type": "custom/type", + }); + expect(Object.fromEntries(requests[1]!)).toEqual({ + "x-gateway": "secret", + authorization: "Bearer session", + "content-type": "application/json", + }); + }); +}); diff --git a/internal/site/lib/api-client.server.ts b/internal/site/lib/api-client.server.ts index af36b4c3..a46d62b9 100644 --- a/internal/site/lib/api-client.server.ts +++ b/internal/site/lib/api-client.server.ts @@ -1,6 +1,7 @@ import Client from "@blink.so/api"; import { cache } from "react"; import { getSessionToken } from "@/app/(auth)/auth"; +import { getAPIHeaders } from "./api-headers.server"; const getBaseUrl = (): string => { return process.env.NEXT_PUBLIC_BASE_URL ?? "http://localhost:3005"; @@ -15,6 +16,7 @@ export const getAPIClient = cache(async (): Promise => { return new Client({ baseURL: getBaseUrl(), authToken: token, + headers: getAPIHeaders(), }); }); @@ -25,5 +27,6 @@ export const getAPIClient = cache(async (): Promise => { export const getPublicAPIClient = cache((): Client => { return new Client({ baseURL: getBaseUrl(), + headers: getAPIHeaders(), }); }); diff --git a/internal/site/lib/api-headers.server.test.ts b/internal/site/lib/api-headers.server.test.ts new file mode 100644 index 00000000..c4a0a354 --- /dev/null +++ b/internal/site/lib/api-headers.server.test.ts @@ -0,0 +1,42 @@ +import { afterEach, expect, test } from "bun:test"; +import { getAPIHeaders } from "./api-headers.server"; + +const original = process.env.BLINK_API_HEADERS; +afterEach(() => { + if (original === undefined) delete process.env.BLINK_API_HEADERS; + else process.env.BLINK_API_HEADERS = original; +}); + +test("absent or empty configuration adds no headers", () => { + delete process.env.BLINK_API_HEADERS; + expect(getAPIHeaders()).toBeUndefined(); + for (const value of ["", " \n "]) { + process.env.BLINK_API_HEADERS = value; + expect(getAPIHeaders()).toBeUndefined(); + } +}); + +test("reads and validates the current environment", () => { + process.env.BLINK_API_HEADERS = '{"X-Gateway":"secret"}'; + expect(getAPIHeaders()).toEqual({ "x-gateway": "secret" }); + process.env.BLINK_API_HEADERS = "{}"; + expect(getAPIHeaders()).toEqual({}); +}); + +test("invalid configuration fails without disclosing credentials", () => { + for (const value of [ + "secret", + "null", + "[]", + '{"X-Gateway":42}', + '{"bad name":"secret"}', + JSON.stringify({ "X-Gateway": "secret\r\ninjected" }), + ]) { + process.env.BLINK_API_HEADERS = value; + expect(getAPIHeaders).toThrow( + new Error( + "BLINK_API_HEADERS must be a JSON object of valid HTTP headers with string values" + ) + ); + } +}); diff --git a/internal/site/lib/api-headers.server.ts b/internal/site/lib/api-headers.server.ts new file mode 100644 index 00000000..8014c538 --- /dev/null +++ b/internal/site/lib/api-headers.server.ts @@ -0,0 +1,24 @@ +/** Read private gateway credentials only when constructing a server API client. */ +export function getAPIHeaders(): Record | undefined { + const value = process.env.BLINK_API_HEADERS; + if (!value?.trim()) return undefined; + + try { + const parsed: unknown = JSON.parse(value); + if ( + !parsed || + typeof parsed !== "object" || + Array.isArray(parsed) || + Object.values(parsed).some((value) => typeof value !== "string") + ) { + throw new Error(); + } + // Validate HTTP names and values and normalize case before passing them on. + return Object.fromEntries(new Headers(parsed as Record)); + } catch { + // Parser and Headers errors can contain credentials; never propagate them. + throw new Error( + "BLINK_API_HEADERS must be a JSON object of valid HTTP headers with string values" + ); + } +}