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..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,9 +329,17 @@ 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, + // 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..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 @@ -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..d32b87e 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; } /** @@ -544,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 9a50634..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", () => { @@ -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,58 @@ 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 }); + // `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(auth.hasToken()).toBe(false); + + auth.setToken("some-token", false); + expect(auth.hasToken()).toBe(true); + + auth.logout(); + expect(auth.hasToken()).toBe(false); + + client.cleanup(); + }); + test("should track multiple events", async () => { vi.useFakeTimers();