diff --git a/services/companion/src/server.test.ts b/services/companion/src/server.test.ts index bdce8ad..02ae01e 100644 --- a/services/companion/src/server.test.ts +++ b/services/companion/src/server.test.ts @@ -83,6 +83,55 @@ describe("createCompanionApp", () => { await app.close(); }); + it("rejects an empty x-webchain-token header", async () => { + const { app } = await createCompanionApp({ + runtime: mockRuntime(), + logger: false, + localToken: "secret", + }); + + const res = await app.inject({ + method: "POST", + url: "/sessions", + headers: { "x-webchain-token": "" }, + }); + + expect(res.statusCode).toBe(401); + await app.close(); + }); + + it("refuses to boot when localToken is empty or whitespace", async () => { + await expect( + createCompanionApp({ + runtime: mockRuntime(), + logger: false, + localToken: "", + }), + ).rejects.toThrow(/WEBCHAIN_LOCAL_TOKEN must be a non-empty string/); + + await expect( + createCompanionApp({ + runtime: mockRuntime(), + logger: false, + localToken: " ", + }), + ).rejects.toThrow(/WEBCHAIN_LOCAL_TOKEN must be a non-empty string/); + }); + + it("refuses to boot when WEBCHAIN_LOCAL_TOKEN is empty", async () => { + vi.stubEnv("WEBCHAIN_LOCAL_TOKEN", ""); + try { + await expect( + createCompanionApp({ + runtime: mockRuntime(), + logger: false, + }), + ).rejects.toThrow(/WEBCHAIN_LOCAL_TOKEN must be a non-empty string/); + } finally { + vi.unstubAllEnvs(); + } + }); + it("creates a session with valid token", async () => { const runtime = mockRuntime(); const { app } = await createCompanionApp({ diff --git a/services/companion/src/server.ts b/services/companion/src/server.ts index c79ee84..5326f2a 100644 --- a/services/companion/src/server.ts +++ b/services/companion/src/server.ts @@ -111,13 +111,43 @@ export type CreateCompanionAppOptions = { logger?: boolean; }; +const DEFAULT_LOCAL_DEV_TOKEN = "change-me-in-local-dev"; + +/** + * `??` keeps empty strings from env/options, which previously matched an empty + * `x-webchain-token` header and authenticated as a valid local token. + * + * @example + * resolveCompanionLocalToken("secret") + */ +function resolveCompanionLocalToken(explicitToken: string | undefined): string { + const raw = + explicitToken ?? + process.env.WEBCHAIN_LOCAL_TOKEN ?? + DEFAULT_LOCAL_DEV_TOKEN; + const token = raw.trim(); + if (token.length === 0) { + throw new Error( + `WEBCHAIN_LOCAL_TOKEN must be a non-empty string; received ${JSON.stringify(raw)}, expected a non-empty token`, + ); + } + return token; +} + +function presentedCompanionToken( + header: string | string[] | undefined, +): string | undefined { + if (typeof header !== "string") { + return undefined; + } + const token = header.trim(); + return token.length > 0 ? token : undefined; +} + export async function createCompanionApp( options: CreateCompanionAppOptions, ): Promise<{ app: FastifyInstance; localToken: string }> { - const localToken = - options.localToken ?? - process.env.WEBCHAIN_LOCAL_TOKEN ?? - "change-me-in-local-dev"; + const localToken = resolveCompanionLocalToken(options.localToken); const app = Fastify({ logger: options.logger ?? true }); @@ -141,7 +171,7 @@ export async function createCompanionApp( return; } - const token = request.headers["x-webchain-token"]; + const token = presentedCompanionToken(request.headers["x-webchain-token"]); if (token !== localToken) { return reply.code(401).send({