diff --git a/src/utils/axios-client.ts b/src/utils/axios-client.ts index 957835c..7354732 100644 --- a/src/utils/axios-client.ts +++ b/src/utils/axios-client.ts @@ -1,6 +1,7 @@ import axios from "axios"; import { isInIFrame } from "./common.js"; import { v4 as uuidv4 } from "uuid"; +import { getAnalyticsSessionId } from "../modules/analytics.js"; import type { Base44ErrorJSON } from "./axios-client.types.js"; /** @@ -176,6 +177,16 @@ export function createAxiosClient({ client.interceptors.request.use((config) => { if (typeof window !== "undefined") { config.headers.set("X-Origin-URL", window.location.href); + // On unauthenticated clients, attach a stable anonymous visitor id so the + // backend can support anonymous agent access (conversation grouping + + // ownership). Authenticated clients are identified by their token instead. + // Reuses the persisted analytics session id so an anonymous agent + // conversation and that visitor's analytics events share one identity. + // (Hardening this id to crypto-strength + decoupling from analytics is a + // tracked follow-up.) + if (!token) { + config.headers.set("X-Base44-Anonymous-Id", getAnalyticsSessionId()); + } } const requestId = uuidv4(); (config as any).requestId = requestId; diff --git a/src/utils/socket-utils.ts b/src/utils/socket-utils.ts index 2914c4f..e05f150 100644 --- a/src/utils/socket-utils.ts +++ b/src/utils/socket-utils.ts @@ -1,5 +1,6 @@ import { Socket, io } from "socket.io-client"; import { getAccessToken } from "./auth-utils.js"; +import { getAnalyticsSessionId } from "../modules/analytics.js"; export interface RoomsSocketConfig { serverUrl: string; @@ -37,13 +38,23 @@ function initializeSocket( config: RoomsSocketConfig, handlers: Partial ) { + // On unauthenticated clients, send a stable anonymous visitor id on the + // handshake so the backend can verify room access for anonymous agent + // conversations (mirrors the X-Base44-Anonymous-Id HTTP header). Authenticated + // clients are identified by their token instead. + const resolvedToken = config.token ?? getAccessToken(); + const query: Record = { + app_id: config.appId, + token: resolvedToken, + }; + if (!resolvedToken) { + query.anonymous_id = getAnalyticsSessionId(); + } + const socket = io(config.serverUrl, { path: config.mountPath, transports: config.transports, - query: { - app_id: config.appId, - token: config.token ?? getAccessToken(), - }, + query, }) as Socket; socket.on("connect", async () => { diff --git a/tests/unit/anonymous-visitor-header.test.ts b/tests/unit/anonymous-visitor-header.test.ts new file mode 100644 index 0000000..b362608 --- /dev/null +++ b/tests/unit/anonymous-visitor-header.test.ts @@ -0,0 +1,66 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { createAxiosClient } from "../../src/utils/axios-client.ts"; +import { getAnalyticsSessionId } from "../../src/modules/analytics.ts"; + +const ANALYTICS_SESSION_ID_KEY = "base44_analytics_session_id"; + +function makeLocalStorage() { + const store = new Map(); + return { + getItem: (k: string) => (store.has(k) ? store.get(k)! : null), + setItem: (k: string, v: string) => { + store.set(k, v); + }, + removeItem: (k: string) => { + store.delete(k); + }, + clear: () => store.clear(), + }; +} + +beforeEach(() => { + const localStorage = makeLocalStorage(); + vi.stubGlobal("window", { + location: { href: "https://my-app.base44.app/" }, + localStorage, + }); + vi.stubGlobal("localStorage", localStorage); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +async function captureRequestHeaders(token?: string) { + const client = createAxiosClient({ baseURL: "https://api", token }); + let captured: any; + client.defaults.adapter = async (config) => { + captured = config; + return { data: {}, status: 200, statusText: "OK", headers: {}, config }; + }; + await client.get("/conversations"); + return captured.headers; +} + +describe("anonymous visitor header", () => { + test("unauthenticated client sends X-Base44-Anonymous-Id", async () => { + const headers = await captureRequestHeaders(undefined); + expect(headers.get("X-Base44-Anonymous-Id")).toBeTruthy(); + expect(headers.get("Authorization")).toBeFalsy(); + }); + + test("header value is the persisted analytics session id (unified identity)", async () => { + const headers = await captureRequestHeaders(undefined); + const sessionId = getAnalyticsSessionId(); + expect(headers.get("X-Base44-Anonymous-Id")).toBe(sessionId); + expect( + (globalThis as any).localStorage.getItem(ANALYTICS_SESSION_ID_KEY) + ).toBe(sessionId); + }); + + test("authenticated client sends Authorization, not the anonymous header", async () => { + const headers = await captureRequestHeaders("a-real-token"); + expect(headers.get("X-Base44-Anonymous-Id")).toBeFalsy(); + expect(headers.get("Authorization")).toBe("Bearer a-real-token"); + }); +}); diff --git a/tests/unit/socket-utils.test.ts b/tests/unit/socket-utils.test.ts index f012a4c..2d58f29 100644 --- a/tests/unit/socket-utils.test.ts +++ b/tests/unit/socket-utils.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { io } from "socket.io-client"; import { RoomsSocket } from "../../src/utils/socket-utils.ts"; const socketMock = vi.hoisted(() => ({ @@ -19,6 +20,14 @@ vi.mock("socket.io-client", () => ({ })), })); +vi.mock("../../src/utils/auth-utils.ts", () => ({ + getAccessToken: vi.fn(() => undefined), +})); + +vi.mock("../../src/modules/analytics.ts", () => ({ + getAnalyticsSessionId: vi.fn(() => "anon-session-123"), +})); + describe("RoomsSocket", () => { beforeEach(() => { socketMock.disconnect.mockClear(); @@ -31,6 +40,39 @@ describe("RoomsSocket", () => { vi.useRealTimers(); }); + describe("handshake auth params", () => { + const baseConfig = { + serverUrl: "https://api.base44.test", + mountPath: "/socket.io/", + transports: ["websocket"], + appId: "test-app-id", + }; + + function lastHandshakeQuery(): Record { + const opts = vi.mocked(io).mock.calls.at(-1)?.[1] as + | { query?: Record } + | undefined; + return opts?.query ?? {}; + } + + test("sends a stable anonymous_id and no token when unauthenticated", () => { + RoomsSocket({ config: { ...baseConfig } }); + + const query = lastHandshakeQuery(); + expect(query.app_id).toBe("test-app-id"); + expect(query.anonymous_id).toBe("anon-session-123"); + expect(query.token).toBeUndefined(); + }); + + test("sends the token and no anonymous_id when authenticated", () => { + RoomsSocket({ config: { ...baseConfig, token: "test-token" } }); + + const query = lastHandshakeQuery(); + expect(query.token).toBe("test-token"); + expect(query.anonymous_id).toBeUndefined(); + }); + }); + function createRoomsSocket() { return RoomsSocket({ config: {