Skip to content
Merged
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
1 change: 1 addition & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ export function createClient(config: CreateClientConfig): Base44Client {
{
appBaseUrl: normalizedAppBaseUrl,
serverUrl,
token,
}
);

Expand Down
14 changes: 11 additions & 3 deletions src/modules/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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__";
Expand Down Expand Up @@ -58,7 +58,7 @@ export interface AnalyticsModuleArgs {
axiosClient: AxiosInstance;
serverUrl: string;
appId: string;
userAuthModule: AuthModule;
userAuthModule: InternalAuthModule;
}

export const createAnalyticsModule = ({
Expand Down Expand Up @@ -329,9 +329,17 @@ export function resetAnalyticsSessionContext() {
}

async function getSessionContext(
userAuthModule: AuthModule
userAuthModule: InternalAuthModule
): Promise<SessionContext> {
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
Expand Down
15 changes: 13 additions & 2 deletions src/modules/auth.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { AxiosInstance } from "axios";
import {
AuthModule,
AuthModuleOptions,
InternalAuthModule,
User,
VerifyOtpParams,
ChangePasswordParams,
Expand Down Expand Up @@ -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
Expand All @@ -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<User> =
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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}`;
Expand Down
24 changes: 24 additions & 0 deletions src/modules/auth.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -544,3 +550,21 @@ export interface AuthModule {
*/
changePassword(params: ChangePasswordParams): Promise<any>;
}

/**
* 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;
}
60 changes: 59 additions & 1 deletion tests/unit/analytics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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<string, string> } },
request: vi.fn().mockResolvedValue({
status: 200,
data: {
Expand Down Expand Up @@ -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",
});
});

Expand Down Expand Up @@ -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();

Expand Down
Loading