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
252 changes: 218 additions & 34 deletions package-lock.json

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,11 @@
"homepage": "https://github.com/Web3Auth/mpc-core-kit/tree/master#readme",
"license": "ISC",
"scripts": {
"sync:sdk-version": "node scripts/sync-sdk-version.mjs",
"pretest": "npm run sync:sdk-version",
"test": "node --test -r esbuild-register tests/*.spec.ts",
"dev": "torus-scripts start",
"prebuild": "npm run sync:sdk-version",
"build": "torus-scripts build",
"release": "torus-scripts release",
"lint": "eslint --fix 'src/**/*.ts'",
Expand All @@ -43,6 +46,7 @@
}
},
"dependencies": {
"@segment/analytics-next": "^1.84.0",
"@tkey/common-types": "^15.1.0",
"@tkey/core": "^15.1.0",
"@tkey/share-serialization": "^15.1.0",
Expand Down Expand Up @@ -75,7 +79,7 @@
"@types/chai": "^4.3.16",
"@types/elliptic": "^6.4.18",
"@types/jsonwebtoken": "^9.0.7",
"@types/node": "^20.14.0",
"@types/node": "^22.20.2",
"@typescript-eslint/parser": "^7.18.0",
"chai": "^5.1.1",
"cross-env": "^7.0.3",
Expand Down
12 changes: 12 additions & 0 deletions scripts/sync-sdk-version.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";

const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const { version } = JSON.parse(fs.readFileSync(path.join(scriptDir, "../package.json"), "utf8"));

const contents = `// Generated from package.json by scripts/sync-sdk-version.mjs. Do not edit.
export const ANALYTICS_SDK_VERSION = ${JSON.stringify(version)};
`;

fs.writeFileSync(path.join(scriptDir, "../src/sdkVersion.ts"), contents);
227 changes: 227 additions & 0 deletions src/analytics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
import type { AnalyticsBrowser, EventProperties, UserTraits } from "@segment/analytics-next";

import { log } from "./utils";

// Public Segment *source* write key (write-only). This is the same key used by
// web3auth-web. It is not a secret: browser SDKs must ship it in the bundle.
// Mixpanel isolation is via sdk_name / web3auth_client_id, not a private key.
const SEGMENT_WRITE_KEY = "f6LbNqCeVRf512ggdME4b6CyflhF1tsX";

export const ANALYTICS_EVENTS = {
SDK_INITIALIZATION_COMPLETED: "SDK Initialization Completed",
SDK_INITIALIZATION_FAILED: "SDK Initialization Failed",
CONNECTION_STARTED: "Connection Started",
CONNECTION_COMPLETED: "Connection Completed",
CONNECTION_FAILED: "Connection Failed",
LOGIN_REQUIRED_SHARE: "Login Required Share",
INPUT_FACTOR_STARTED: "Input Factor Started",
INPUT_FACTOR_COMPLETED: "Input Factor Completed",
INPUT_FACTOR_FAILED: "Input Factor Failed",
MFA_ENABLEMENT_STARTED: "MFA Enablement Started",
MFA_ENABLEMENT_COMPLETED: "MFA Enablement Completed",
MFA_ENABLEMENT_FAILED: "MFA Enablement Failed",
SESSION_REHYDRATION_COMPLETED: "Session Rehydration Completed",
SESSION_REHYDRATION_FAILED: "Session Rehydration Failed",
SESSION_CREATION_FAILED: "Session Creation Failed",
LOGOUT_COMPLETED: "Logout Completed",
LOGOUT_FAILED: "Logout Failed",
FACTOR_CREATION_COMPLETED: "Factor Creation Completed",
FACTOR_CREATION_FAILED: "Factor Creation Failed",
FACTOR_DELETION_COMPLETED: "Factor Deletion Completed",
FACTOR_DELETION_FAILED: "Factor Deletion Failed",
} as const;

export const ANALYTICS_SDK_NAME = "MPC Core Kit";
export const ANALYTICS_INTEGRATION_TYPE = "Native SDK";
export { ANALYTICS_SDK_VERSION } from "./sdkVersion";

export type InputFactorFailureReason = "invalid_factor" | "infra_error";

export type AnalyticsClient = Pick<AnalyticsBrowser, "identify" | "track">;
export type AnalyticsClientFactory = () => Promise<AnalyticsClient>;

export interface AnalyticsOptions {
disabled?: boolean;
clientFactory?: AnalyticsClientFactory;
}

function unwrapAnalyticsClient(client: AnalyticsClient): AnalyticsClient {
// AnalyticsBrowser is PromiseLike<[Analytics, Context]>. An async factory
// that returns it resolves to that tuple instead of the client. Returning a
// plain object also prevents later `await client` from unwrapping it again.
const value = client as AnalyticsClient | [AnalyticsClient, unknown];
const resolved = Array.isArray(value) && value[0] && typeof value[0].track === "function" ? value[0] : client;
return {
identify: resolved.identify.bind(resolved),
track: resolved.track.bind(resolved),
};
}

export class Analytics {
private client?: AnalyticsClient;

private initializationPromise?: Promise<AnalyticsClient | undefined>;

private globalProperties: Record<string, unknown> = {};

private readonly disabled: boolean;

private readonly clientFactory: AnalyticsClientFactory;

public constructor(options: AnalyticsOptions) {
this.disabled = Boolean(options.disabled);
this.clientFactory =
options.clientFactory ||
(async () => {
const { AnalyticsBrowser } = await import("@segment/analytics-next");
const segment = new AnalyticsBrowser();
await segment.load(
{ writeKey: SEGMENT_WRITE_KEY },
{
user: {
cookie: { key: "web3auth_ajs_user_id" },
localStorage: { key: "web3auth_ajs_user_traits" },
},
globalAnalyticsKey: "web3auth_analytics",
}
);
// AnalyticsBrowser is a PromiseLike<[Analytics, Context]>, so returning it
// directly from an async function would resolve to that tuple instead of the client.
return {
identify: segment.identify.bind(segment),
track: segment.track.bind(segment),
};
});
}

public init(): void {
if (this.isSkipped() || this.initializationPromise) return;

this.initializationPromise = this.clientFactory()
.then((client) => {
this.client = unwrapAnalyticsClient(client);
return this.client;
})
.catch((error: unknown): AnalyticsClient | undefined => {
log.error("Failed to initialize analytics", error);
return undefined;
});
}

public setGlobalProperties(properties: Record<string, unknown>): void {
this.globalProperties = { ...this.globalProperties, ...properties };
}

public async identify(userId: string, traits?: UserTraits): Promise<void> {
if (this.isSkipped()) return;
try {
const client = await this.getClient();
await client?.identify(userId, traits);
} catch (error) {
log.error(`Failed to identify client ${userId} in analytics`, error);
}
}

public async track(event: string, properties?: EventProperties): Promise<void> {
if (this.isSkipped()) return;
try {
const client = await this.getClient();
await client?.track(event, { ...this.globalProperties, ...properties });
} catch (error) {
log.error(`Failed to track event ${event}`, error);
}
}

private async getClient(): Promise<AnalyticsClient | undefined> {
if (!this.initializationPromise) this.init();
return this.client || this.initializationPromise;
}

private isSkipped(): boolean {
if (this.disabled) return true;
if (typeof window === "undefined") return true;
const dappOrigin = window.location?.origin || "";
try {
const url = new URL(dappOrigin);
return (
url.protocol !== "https:" ||
url.hostname === "localhost" ||
url.hostname === "127.0.0.1" ||
url.hostname === "::1" ||
url.hostname === "[::1]"
);
} catch {
return true;
}
}
}

function sanitizeErrorMessage(message: string): string {
return message
.replace(/\beyJ[\w-]+\.[\w-]+\.[\w-]+\b/g, "[REDACTED_TOKEN]")
.replace(/\b(?:0x)?[a-fA-F0-9]{64,}\b/g, "[REDACTED_KEY]")
.replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, "[REDACTED_EMAIL]")
.slice(0, 500);
}

const CONNECTION_TRACK_ALLOWED_KEYS = new Set(["login_method", "verifier", "auth_connection", "is_aggregate_verifier"]);

function sanitizeConnectionTrackDataForStorage(trackData: Record<string, unknown>): Record<string, unknown> {
const sanitized: Record<string, unknown> = {};
Object.entries(trackData).forEach(([key, value]) => {
if (!CONNECTION_TRACK_ALLOWED_KEYS.has(key)) return;
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean" || value === null) {
sanitized[key] = value;
}
});
return sanitized;
}

export const CONNECTION_TRACK_STORAGE_KEY = "web3auth_mpc_connection_track";

export function persistPendingConnectionTrackData(trackData: Record<string, unknown>): void {
if (typeof window === "undefined") return;
try {
const sanitizedTrackData = sanitizeConnectionTrackDataForStorage(trackData);
window.sessionStorage.setItem(CONNECTION_TRACK_STORAGE_KEY, JSON.stringify(sanitizedTrackData));
} catch (error) {
log.error("Failed to persist pending connection track data", error);
}
}

export function consumePendingConnectionTrackData(): Record<string, unknown> | undefined {
if (typeof window === "undefined") return undefined;
try {
const raw = window.sessionStorage.getItem(CONNECTION_TRACK_STORAGE_KEY);
if (!raw) return undefined;
window.sessionStorage.removeItem(CONNECTION_TRACK_STORAGE_KEY);
const parsed = JSON.parse(raw) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
return sanitizeConnectionTrackDataForStorage(parsed as Record<string, unknown>);
} catch (error) {
log.error("Failed to consume pending connection track data", error);
return undefined;
}
}

export function getErrorAnalyticsProperties(error: unknown): { error_code?: number | string; error_message: string } {
const analyticsError = error as { code?: number | string; message?: string };
const message = analyticsError?.message || String(error) || "Unknown error";
return {
...(analyticsError?.code !== undefined ? { error_code: analyticsError.code } : {}),
error_message: sanitizeErrorMessage(message),
};
}

export function getInputFactorFailureReason(error: unknown): InputFactorFailureReason {
const analyticsError = error as { code?: number; message?: string };
if (
analyticsError?.code === 1207 ||
analyticsError?.code === 1209 ||
/invalid factor\s*key/i.test(analyticsError?.message || "") ||
analyticsError?.message?.toLowerCase().includes("no metadata found")
) {
return "invalid_factor";
}
return "infra_error";
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./analytics";
export * from "./constants";
export * from "./helper";
export * from "./interfaces";
Expand Down
14 changes: 12 additions & 2 deletions src/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,9 +237,9 @@ export interface ICoreKit {
*
* @param enableMFAParams - Parameters for recovery factor for MFA.
* @param recoveryFactor - Default is true. If false, recovery factor will NOT be created.
* @returns The backup factor key if if recoveryFacort is true else empty string.
* @returns The backup factor key when recoveryFactor is true; otherwise undefined.
*/
enableMFA(enableMFAParams: EnableMFAParams, recoveryFactor?: boolean): Promise<string>;
enableMFA(enableMFAParams: EnableMFAParams, recoveryFactor?: boolean): Promise<string | undefined>;

/**
* Second step for login where the user inputs their factor key.
Expand Down Expand Up @@ -378,6 +378,16 @@ export interface Web3AuthOptions {
*/
enableLogging?: boolean;

/**
* Disables anonymous SDK usage analytics.
*
* Analytics are enabled for browser integrations on secure, non-localhost origins.
* Events include `web3auth_network` so dashboards can filter mainnet vs devnet.
*
* @defaultValue `false`
*/
disableAnalytics?: boolean;

/**
* This option is used to specify the url path where user will be
* redirected after login. Redirect Uri for OAuth is baseUrl/redirectPathName.
Expand Down
Loading
Loading