From bc91efb7f751414bf1896140dcd78579095fcf05 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 16:52:28 +0000 Subject: [PATCH 1/2] fix(analytics): skip the identity lookup when no token is set On a public page the analytics module still issued a `User/me` at client construction: `trackInitializationEvent` queues an event, the processor flushes the first batch immediately, and `flush` awaits `getSessionContext`, which calls `auth.me()` unconditionally. With no session that request can only answer 401, and the browser logs it to the console from the network layer -- before any handler in the SDK sees the rejection. The existing `.catch` in `getSessionContext` and the try/catch in `flush` already keep the rejection from escaping, so no amount of additional catching removes what users actually see. Skip the lookup when no access token is set. Anonymous events already reported `user_id: null` through that same `.catch`, so no analytics data changes -- the request is dropped only in the case where its sole possible outcome was a 401. The dedupe from #245 is unaffected: when a token is present, analytics still resolves through `auth.me()` and shares the app's in-flight request, so an authenticated cold load continues to issue exactly one `User/me`. Token presence is tracked in the auth module rather than read off `axios.defaults`, so it follows the identity transitions that already exist there (`setToken`, `logout`) and is seeded from the constructor token, which is how the server-side SDK carries a session it never sets explicitly. The skip is deliberately not memoized: a visitor who logs in mid-session must start resolving an identity again. This does not cover a stale or expired token, which still 401s. Removing that one needs `User/me` to answer 200 with a null user instead of 401, which is a backend change. Tests: the two analytics tests that exercise identity resolution now build a token-bearing client; the axios mock gains the `defaults` that `setToken` and `logout` write through. Three new tests cover the anonymous skip, resolution resuming after `setToken`, and token presence across identity changes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MtKFfzaVR6nWAi8tNvJqfw --- src/client.ts | 1 + src/modules/analytics.ts | 8 ++++++ src/modules/auth.ts | 11 ++++++++ src/modules/auth.types.ts | 17 +++++++++++ tests/unit/analytics.test.ts | 55 ++++++++++++++++++++++++++++++++++++ 5 files changed, 92 insertions(+) diff --git a/src/client.ts b/src/client.ts index 54bf904..d11f687 100644 --- a/src/client.ts +++ b/src/client.ts @@ -150,6 +150,7 @@ export function createClient(config: CreateClientConfig): Base44Client { { appBaseUrl: normalizedAppBaseUrl, serverUrl, + token, } ); diff --git a/src/modules/analytics.ts b/src/modules/analytics.ts index d62b879..c784f99 100644 --- a/src/modules/analytics.ts +++ b/src/modules/analytics.ts @@ -332,6 +332,14 @@ async function getSessionContext( userAuthModule: AuthModule ): Promise { if (!analyticsSharedState.sessionContext) { + // With no token there is no identity to resolve: `me()` can only answer 401, + // which the browser logs to the console before any handler here sees it. On + // a public page that request is the sole reason an error appears, so skip + // it. This is not memoized — a visitor who logs in later must still resolve. + if (!userAuthModule.hasToken()) { + return { user_id: null, session_id: getAnalyticsSessionId() }; + } + if (!sessionContextPromise) { const sessionId = getAnalyticsSessionId(); sessionContextPromise = userAuthModule diff --git a/src/modules/auth.ts b/src/modules/auth.ts index fd6a68d..b1c5f71 100644 --- a/src/modules/auth.ts +++ b/src/modules/auth.ts @@ -108,7 +108,16 @@ export function createAuthModule( pendingMe = null; }; + // Tracked here rather than read off `axios.defaults` so the answer stays tied + // to the identity transitions below (`setToken`, `logout`) instead of to the + // header a caller may have set on the instance directly. + let hasAccessToken = Boolean(options.token); + return { + hasToken() { + return hasAccessToken; + }, + // Get current user information async me() { const request: Promise = @@ -189,6 +198,7 @@ export function createAuthModule( // flight would otherwise resolve into callers that run after the logout. clearPendingMe(); resetAnalyticsSessionContext(); + hasAccessToken = false; // Only do the rest if in a browser environment if (typeof window !== "undefined") { @@ -220,6 +230,7 @@ export function createAuthModule( // resolved for the previous one must not be handed to later callers. clearPendingMe(); resetAnalyticsSessionContext(); + hasAccessToken = true; // handle token change for axios clients axios.defaults.headers.common["Authorization"] = `Bearer ${token}`; diff --git a/src/modules/auth.types.ts b/src/modules/auth.types.ts index 3064540..b66e490 100644 --- a/src/modules/auth.types.ts +++ b/src/modules/auth.types.ts @@ -100,6 +100,12 @@ export interface AuthModuleOptions { serverUrl: string; /** Base URL for the app (used for login redirects). */ appBaseUrl: string; + /** + * Access token the client was constructed with, if any. Seeds the module's + * view of whether a session exists before {@link AuthModule.setToken} runs, + * which is how the server-side SDK reports a token it never sets explicitly. + */ + token?: string; } /** @@ -120,6 +126,17 @@ export interface AuthModuleOptions { * The auth module is only available in user authentication mode (`base44.auth`). */ export interface AuthModule { + /** + * Whether an access token is currently set on the client. + * + * Reports only the presence of a token, never its validity — an expired or + * revoked token still reads as `true`. Callers use this to skip requests that + * could not succeed without a session, not to decide that one is valid. + * + * @internal + */ + hasToken(): boolean; + /** * Gets the current authenticated user's information. * diff --git a/tests/unit/analytics.test.ts b/tests/unit/analytics.test.ts index 9a50634..64535b7 100644 --- a/tests/unit/analytics.test.ts +++ b/tests/unit/analytics.test.ts @@ -26,6 +26,9 @@ describe("Analytics Module", () => { createAxiosClient: vi.fn().mockImplementation( () => ({ + // `setToken` and `logout` write through to these, so the mock needs + // them present per instance. + defaults: { headers: { common: {} as Record } }, request: vi.fn().mockResolvedValue({ status: 200, data: { @@ -54,9 +57,12 @@ describe("Analytics Module", () => { heartBeatInterval: undefined, }; + // Token-bearing by default: most tests here exercise the flush path that + // resolves an identity, and that lookup is skipped without a session. base44 = createClient({ serverUrl, appId, + token: "test-access-token", }); }); @@ -126,6 +132,55 @@ describe("Analytics Module", () => { expect(heartBeatState.isHeartBeatProcessing).toBeFalsy(); }); + test("should not resolve an identity when no token is set", async () => { + resetAnalyticsSessionContext(); + + const anonymous = createClient({ serverUrl, appId }); + const me = vi.spyOn(anonymous.auth, "me"); + + anonymous.analytics.track({ eventName: "public-page-event" }); + await vi.waitFor(() => expect(sharedState?.requestsQueue.length).toBe(0)); + + // The whole point: on a public page `me()` can only answer 401, and the + // browser logs that to the console before any handler here sees it. The + // event still flushes -- anonymous events already reported user_id: null. + expect(me).not.toHaveBeenCalled(); + + anonymous.cleanup(); + }); + + test("should resolve an identity once a token is set", async () => { + resetAnalyticsSessionContext(); + + const anonymous = createClient({ serverUrl, appId }); + const me = vi + .spyOn(anonymous.auth, "me") + .mockResolvedValue({ id: "user-1" } as User); + + // A visitor who logs in mid-session must start reporting their identity, so + // the skip above must not be memoized. + anonymous.auth.setToken("token-acquired-after-login", false); + anonymous.analytics.track({ eventName: "post-login-event" }); + + await vi.waitFor(() => expect(me).toHaveBeenCalled()); + + anonymous.cleanup(); + }); + + test("should report token presence across identity changes", () => { + const client = createClient({ serverUrl, appId }); + + expect(client.auth.hasToken()).toBe(false); + + client.auth.setToken("some-token", false); + expect(client.auth.hasToken()).toBe(true); + + client.auth.logout(); + expect(client.auth.hasToken()).toBe(false); + + client.cleanup(); + }); + test("should track multiple events", async () => { vi.useFakeTimers(); From 79195b0f56a00b80031fc0faeffb5e79d260eff2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 07:51:03 +0000 Subject: [PATCH 2/2] refactor(auth): keep hasToken() off the public AuthModule type Review feedback on #255: despite @internal, hasToken() landed in the published .d.ts (tsc does not strip @internal members without stripInternal), so every consumer saw it on base44.auth. Move it to InternalAuthModule, an extension of AuthModule that only the factory and the analytics module reference. It is not exported from the package index, and Base44Client.auth stays typed as AuthModule, so the runtime object still carries the method but the public type surface is unchanged from before this PR. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MtKFfzaVR6nWAi8tNvJqfw --- src/modules/analytics.ts | 6 +++--- src/modules/auth.ts | 4 ++-- src/modules/auth.types.ts | 29 ++++++++++++++++++----------- tests/unit/analytics.test.ts | 15 +++++++++------ 4 files changed, 32 insertions(+), 22 deletions(-) diff --git a/src/modules/analytics.ts b/src/modules/analytics.ts index c784f99..b294849 100644 --- a/src/modules/analytics.ts +++ b/src/modules/analytics.ts @@ -9,7 +9,7 @@ import { SessionContext, } from "./analytics.types"; import { getSharedInstance } from "../utils/sharedInstance.js"; -import type { AuthModule } from "./auth.types"; +import type { InternalAuthModule } from "./auth.types"; import { generateUuid, isReactNative } from "../utils/common.js"; export const USER_HEARTBEAT_EVENT_NAME = "__user_heartbeat_event__"; @@ -58,7 +58,7 @@ export interface AnalyticsModuleArgs { axiosClient: AxiosInstance; serverUrl: string; appId: string; - userAuthModule: AuthModule; + userAuthModule: InternalAuthModule; } export const createAnalyticsModule = ({ @@ -329,7 +329,7 @@ export function resetAnalyticsSessionContext() { } async function getSessionContext( - userAuthModule: AuthModule + userAuthModule: InternalAuthModule ): Promise { if (!analyticsSharedState.sessionContext) { // With no token there is no identity to resolve: `me()` can only answer 401, diff --git a/src/modules/auth.ts b/src/modules/auth.ts index b1c5f71..b9e2374 100644 --- a/src/modules/auth.ts +++ b/src/modules/auth.ts @@ -1,7 +1,7 @@ import { AxiosInstance } from "axios"; import { - AuthModule, AuthModuleOptions, + InternalAuthModule, User, VerifyOtpParams, ChangePasswordParams, @@ -92,7 +92,7 @@ export function createAuthModule( functionsAxiosClient: AxiosInstance, appId: string, options: AuthModuleOptions -): AuthModule { +): InternalAuthModule { // In-flight `me()` request, shared by concurrent callers. The analytics // module resolves its session context through `me()` at client construction, // at the same moment most apps issue their own `me()`. Browsers serialize the diff --git a/src/modules/auth.types.ts b/src/modules/auth.types.ts index b66e490..d32b87e 100644 --- a/src/modules/auth.types.ts +++ b/src/modules/auth.types.ts @@ -126,17 +126,6 @@ export interface AuthModuleOptions { * The auth module is only available in user authentication mode (`base44.auth`). */ export interface AuthModule { - /** - * Whether an access token is currently set on the client. - * - * Reports only the presence of a token, never its validity — an expired or - * revoked token still reads as `true`. Callers use this to skip requests that - * could not succeed without a session, not to decide that one is valid. - * - * @internal - */ - hasToken(): boolean; - /** * Gets the current authenticated user's information. * @@ -561,3 +550,21 @@ export interface AuthModule { */ changePassword(params: ChangePasswordParams): Promise; } + +/** + * The auth module as constructed internally, before it is narrowed to + * {@link AuthModule} on the public client. Not exported from the package + * index — SDK consumers see only {@link AuthModule}. + * + * @internal + */ +export interface InternalAuthModule extends AuthModule { + /** + * Whether an access token is currently set on the client. + * + * Reports only the presence of a token, never its validity — an expired or + * revoked token still reads as `true`. Callers use this to skip requests that + * could not succeed without a session, not to decide that one is valid. + */ + hasToken(): boolean; +} diff --git a/tests/unit/analytics.test.ts b/tests/unit/analytics.test.ts index 64535b7..3ca6e87 100644 --- a/tests/unit/analytics.test.ts +++ b/tests/unit/analytics.test.ts @@ -7,7 +7,7 @@ import { } from "../../src/index.ts"; import { getSharedInstance } from "../../src/utils/sharedInstance.ts"; import { resetAnalyticsSessionContext } from "../../src/modules/analytics.ts"; -import { User } from "../../src/modules/auth.types.ts"; +import { InternalAuthModule, User } from "../../src/modules/auth.types.ts"; import { AxiosInstance } from "axios"; describe("Analytics Module", () => { @@ -169,14 +169,17 @@ describe("Analytics Module", () => { test("should report token presence across identity changes", () => { const client = createClient({ serverUrl, appId }); + // `hasToken` lives on the internal auth surface only; the public client + // narrows to AuthModule, so reach past the narrowing deliberately here. + const auth = client.auth as InternalAuthModule; - expect(client.auth.hasToken()).toBe(false); + expect(auth.hasToken()).toBe(false); - client.auth.setToken("some-token", false); - expect(client.auth.hasToken()).toBe(true); + auth.setToken("some-token", false); + expect(auth.hasToken()).toBe(true); - client.auth.logout(); - expect(client.auth.hasToken()).toBe(false); + auth.logout(); + expect(auth.hasToken()).toBe(false); client.cleanup(); });