Skip to content
Open
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
11 changes: 11 additions & 0 deletions src/utils/axios-client.ts
Original file line number Diff line number Diff line change
@@ -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";

/**
Expand Down Expand Up @@ -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;
Expand Down
19 changes: 15 additions & 4 deletions src/utils/socket-utils.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -37,13 +38,23 @@ function initializeSocket(
config: RoomsSocketConfig,
handlers: Partial<RoomsSocketEventsMap["listen"]>
) {
// 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<string, string | null | undefined> = {
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<RoomsSocketEventsMap["listen"], RoomsSocketEventsMap["emit"]>;

socket.on("connect", async () => {
Expand Down
66 changes: 66 additions & 0 deletions tests/unit/anonymous-visitor-header.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>();
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");
});
});
42 changes: 42 additions & 0 deletions tests/unit/socket-utils.test.ts
Original file line number Diff line number Diff line change
@@ -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(() => ({
Expand All @@ -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();
Expand All @@ -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<string, unknown> {
const opts = vi.mocked(io).mock.calls.at(-1)?.[1] as
| { query?: Record<string, unknown> }
| 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: {
Expand Down
Loading