From 86de2abfebdade04df22bb41a3d22bb1293c73e3 Mon Sep 17 00:00:00 2001 From: johnny Date: Tue, 16 Jun 2026 10:36:53 +0300 Subject: [PATCH 1/3] feat(agents): send X-Base44-Anonymous-Id on unauthenticated clients MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unauthenticated clients now attach a stable, browser-persisted anonymous visitor id (X-Base44-Anonymous-Id) to every request. This lets the Base44 backend support anonymous in-app agent access — grouping an anonymous visitor's conversations and enforcing ownership — without a signed-in user. - New getOrCreateAnonymousVisitorId() helper (localStorage-backed UUID, browser-only; returns null on the server so no header is sent there). - Header is attached in the shared axios request interceptor only when the client has no token; authenticated clients are identified by their token. Pairs with the platform-side anonymous in-app agent access work. --- src/utils/anon-visitor.ts | 42 ++++++++++++++++++ src/utils/axios-client.ts | 10 +++++ tests/unit/anon-visitor.test.ts | 79 +++++++++++++++++++++++++++++++++ 3 files changed, 131 insertions(+) create mode 100644 src/utils/anon-visitor.ts create mode 100644 tests/unit/anon-visitor.test.ts diff --git a/src/utils/anon-visitor.ts b/src/utils/anon-visitor.ts new file mode 100644 index 0000000..dc0c279 --- /dev/null +++ b/src/utils/anon-visitor.ts @@ -0,0 +1,42 @@ +import { v4 as uuidv4 } from "uuid"; + +/** + * localStorage key holding the anonymous visitor id. + * @internal + */ +const ANONYMOUS_VISITOR_ID_STORAGE_KEY = "base44_anonymous_visitor_id"; + +/** + * Returns a stable per-browser identifier for an unauthenticated ("anonymous") + * visitor, creating and persisting one on first use. + * + * Sent as the `X-Base44-Anonymous-Id` header on unauthenticated requests so the + * backend can group an anonymous user's agent conversations and enforce + * ownership (the header is treated as a bearer-style credential). Persisted in + * localStorage so it survives reloads; a new browser/device or cleared storage + * starts a fresh anonymous identity. + * + * Returns `null` outside the browser (no `localStorage` / SSR) so callers can + * skip the header on the server. + * + * @internal + */ +export function getOrCreateAnonymousVisitorId(): string | null { + if (typeof window === "undefined" || !window.localStorage) { + return null; + } + try { + const existing = window.localStorage.getItem( + ANONYMOUS_VISITOR_ID_STORAGE_KEY + ); + if (existing) { + return existing; + } + const minted = uuidv4(); + window.localStorage.setItem(ANONYMOUS_VISITOR_ID_STORAGE_KEY, minted); + return minted; + } catch { + // localStorage unavailable (private mode / sandboxed iframe): no stable id. + return null; + } +} diff --git a/src/utils/axios-client.ts b/src/utils/axios-client.ts index 957835c..61b444d 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 { getOrCreateAnonymousVisitorId } from "./anon-visitor.js"; import type { Base44ErrorJSON } from "./axios-client.types.js"; /** @@ -176,6 +177,15 @@ 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. + if (!token) { + const anonymousVisitorId = getOrCreateAnonymousVisitorId(); + if (anonymousVisitorId) { + config.headers.set("X-Base44-Anonymous-Id", anonymousVisitorId); + } + } } const requestId = uuidv4(); (config as any).requestId = requestId; diff --git a/tests/unit/anon-visitor.test.ts b/tests/unit/anon-visitor.test.ts new file mode 100644 index 0000000..8a2d931 --- /dev/null +++ b/tests/unit/anon-visitor.test.ts @@ -0,0 +1,79 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { getOrCreateAnonymousVisitorId } from "../../src/utils/anon-visitor.ts"; +import { createAxiosClient } from "../../src/utils/axios-client.ts"; + +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(), + }; +} + +function stubBrowser(localStorage = makeLocalStorage()) { + vi.stubGlobal("window", { + location: { href: "https://my-app.base44.app/" }, + localStorage, + }); + return localStorage; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("getOrCreateAnonymousVisitorId", () => { + test("returns null outside the browser (no window)", () => { + expect(getOrCreateAnonymousVisitorId()).toBeNull(); + }); + + test("mints, persists, and is stable across calls", () => { + const ls = stubBrowser(); + const first = getOrCreateAnonymousVisitorId(); + expect(first).toBeTruthy(); + expect(ls.getItem("base44_anonymous_visitor_id")).toBe(first); + expect(getOrCreateAnonymousVisitorId()).toBe(first); + }); + + test("starts a fresh id after storage is cleared", () => { + const ls = stubBrowser(); + const first = getOrCreateAnonymousVisitorId(); + ls.clear(); + const second = getOrCreateAnonymousVisitorId(); + expect(second).toBeTruthy(); + expect(second).not.toBe(first); + }); +}); + +describe("createAxiosClient anonymous-visitor header", () => { + 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; + } + + test("attaches X-Base44-Anonymous-Id when there is no token", async () => { + stubBrowser(); + const headers = await captureRequestHeaders(undefined); + expect(headers.get("X-Base44-Anonymous-Id")).toBeTruthy(); + expect(headers.get("Authorization")).toBeFalsy(); + }); + + test("does NOT attach the anonymous header when authenticated", async () => { + stubBrowser(); + const headers = await captureRequestHeaders("a-real-token"); + expect(headers.get("X-Base44-Anonymous-Id")).toBeFalsy(); + expect(headers.get("Authorization")).toBe("Bearer a-real-token"); + }); +}); From 1ea037ea45490c279aaa38e834cfce5632387dc9 Mon Sep 17 00:00:00 2001 From: johnny Date: Tue, 16 Jun 2026 10:46:34 +0300 Subject: [PATCH 2/3] refactor(agents): unify anonymous visitor id with analytics session id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reuse the existing persisted analytics session id (getAnalyticsSessionId, localStorage `base44_analytics_session_id`) as the X-Base44-Anonymous-Id value instead of minting a separate dedicated id. One identity per anonymous visitor across analytics + agents (enables correlation), and a single source of truth for the id. Follow-up (deferred): harden the id generator to crypto-strength and/or decouple agent identity from analytics — the current generateUuid is Math.random-based, acceptable short-term but weak for a bearer-style ownership credential. - Drop src/utils/anon-visitor.ts; axios-client uses getAnalyticsSessionId. - Rename test to anonymous-visitor-header.test.ts; assert the header equals the persisted analytics session id and is absent when authenticated. --- src/utils/anon-visitor.ts | 42 ----------- src/utils/axios-client.ts | 11 +-- tests/unit/anon-visitor.test.ts | 79 --------------------- tests/unit/anonymous-visitor-header.test.ts | 66 +++++++++++++++++ 4 files changed, 72 insertions(+), 126 deletions(-) delete mode 100644 src/utils/anon-visitor.ts delete mode 100644 tests/unit/anon-visitor.test.ts create mode 100644 tests/unit/anonymous-visitor-header.test.ts diff --git a/src/utils/anon-visitor.ts b/src/utils/anon-visitor.ts deleted file mode 100644 index dc0c279..0000000 --- a/src/utils/anon-visitor.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { v4 as uuidv4 } from "uuid"; - -/** - * localStorage key holding the anonymous visitor id. - * @internal - */ -const ANONYMOUS_VISITOR_ID_STORAGE_KEY = "base44_anonymous_visitor_id"; - -/** - * Returns a stable per-browser identifier for an unauthenticated ("anonymous") - * visitor, creating and persisting one on first use. - * - * Sent as the `X-Base44-Anonymous-Id` header on unauthenticated requests so the - * backend can group an anonymous user's agent conversations and enforce - * ownership (the header is treated as a bearer-style credential). Persisted in - * localStorage so it survives reloads; a new browser/device or cleared storage - * starts a fresh anonymous identity. - * - * Returns `null` outside the browser (no `localStorage` / SSR) so callers can - * skip the header on the server. - * - * @internal - */ -export function getOrCreateAnonymousVisitorId(): string | null { - if (typeof window === "undefined" || !window.localStorage) { - return null; - } - try { - const existing = window.localStorage.getItem( - ANONYMOUS_VISITOR_ID_STORAGE_KEY - ); - if (existing) { - return existing; - } - const minted = uuidv4(); - window.localStorage.setItem(ANONYMOUS_VISITOR_ID_STORAGE_KEY, minted); - return minted; - } catch { - // localStorage unavailable (private mode / sandboxed iframe): no stable id. - return null; - } -} diff --git a/src/utils/axios-client.ts b/src/utils/axios-client.ts index 61b444d..7354732 100644 --- a/src/utils/axios-client.ts +++ b/src/utils/axios-client.ts @@ -1,7 +1,7 @@ import axios from "axios"; import { isInIFrame } from "./common.js"; import { v4 as uuidv4 } from "uuid"; -import { getOrCreateAnonymousVisitorId } from "./anon-visitor.js"; +import { getAnalyticsSessionId } from "../modules/analytics.js"; import type { Base44ErrorJSON } from "./axios-client.types.js"; /** @@ -180,11 +180,12 @@ export function createAxiosClient({ // 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) { - const anonymousVisitorId = getOrCreateAnonymousVisitorId(); - if (anonymousVisitorId) { - config.headers.set("X-Base44-Anonymous-Id", anonymousVisitorId); - } + config.headers.set("X-Base44-Anonymous-Id", getAnalyticsSessionId()); } } const requestId = uuidv4(); diff --git a/tests/unit/anon-visitor.test.ts b/tests/unit/anon-visitor.test.ts deleted file mode 100644 index 8a2d931..0000000 --- a/tests/unit/anon-visitor.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import { getOrCreateAnonymousVisitorId } from "../../src/utils/anon-visitor.ts"; -import { createAxiosClient } from "../../src/utils/axios-client.ts"; - -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(), - }; -} - -function stubBrowser(localStorage = makeLocalStorage()) { - vi.stubGlobal("window", { - location: { href: "https://my-app.base44.app/" }, - localStorage, - }); - return localStorage; -} - -afterEach(() => { - vi.unstubAllGlobals(); -}); - -describe("getOrCreateAnonymousVisitorId", () => { - test("returns null outside the browser (no window)", () => { - expect(getOrCreateAnonymousVisitorId()).toBeNull(); - }); - - test("mints, persists, and is stable across calls", () => { - const ls = stubBrowser(); - const first = getOrCreateAnonymousVisitorId(); - expect(first).toBeTruthy(); - expect(ls.getItem("base44_anonymous_visitor_id")).toBe(first); - expect(getOrCreateAnonymousVisitorId()).toBe(first); - }); - - test("starts a fresh id after storage is cleared", () => { - const ls = stubBrowser(); - const first = getOrCreateAnonymousVisitorId(); - ls.clear(); - const second = getOrCreateAnonymousVisitorId(); - expect(second).toBeTruthy(); - expect(second).not.toBe(first); - }); -}); - -describe("createAxiosClient anonymous-visitor header", () => { - 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; - } - - test("attaches X-Base44-Anonymous-Id when there is no token", async () => { - stubBrowser(); - const headers = await captureRequestHeaders(undefined); - expect(headers.get("X-Base44-Anonymous-Id")).toBeTruthy(); - expect(headers.get("Authorization")).toBeFalsy(); - }); - - test("does NOT attach the anonymous header when authenticated", async () => { - stubBrowser(); - 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/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"); + }); +}); From fb719078f556fe4157da17884080895276a96763 Mon Sep 17 00:00:00 2001 From: johnny Date: Sun, 21 Jun 2026 09:47:03 +0300 Subject: [PATCH 3/3] send anonymous_id on socket --- src/utils/socket-utils.ts | 19 +++++++++++---- tests/unit/socket-utils.test.ts | 42 +++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 4 deletions(-) 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/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: {