Skip to content

Commit f66f41c

Browse files
authored
feat: add support for billing auth sessions (#73)
1 parent c0c9024 commit f66f41c

File tree

4 files changed

+626
-9
lines changed

4 files changed

+626
-9
lines changed

packages/sdk/src/browser.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ export {
8989
type AppInfoResponse,
9090
} from "./client/common/utils/userapi";
9191

92-
export { BillingApiClient } from "./client/common/utils/billingapi";
92+
export { BillingApiClient, type BillingApiClientOptions } from "./client/common/utils/billingapi";
9393

9494
export { BuildApiClient, type BuildApiClientOptions } from "./client/common/utils/buildapi";
9595

@@ -196,6 +196,23 @@ export {
196196
type LoginRequest,
197197
} from "./client/common/auth/session";
198198

199+
// =============================================================================
200+
// Billing API Session Management (browser-safe)
201+
// =============================================================================
202+
export {
203+
loginToBillingApi,
204+
logoutFromBillingApi,
205+
getBillingApiSession,
206+
isBillingSessionValid,
207+
loginToBothApis,
208+
logoutFromBothApis,
209+
BillingSessionError,
210+
type BillingApiConfig,
211+
type BillingSessionInfo,
212+
type BillingLoginResult,
213+
type BillingLoginRequest,
214+
} from "./client/common/auth/billingSession";
215+
199216
// =============================================================================
200217
// React Hooks (requires React 18+ as peer dependency)
201218
// =============================================================================
Lines changed: 355 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,355 @@
1+
/**
2+
* Billing API Session Management
3+
*
4+
* This module provides utilities for managing authentication sessions with the billing API
5+
* using SIWE (Sign-In with Ethereum).
6+
*
7+
* The billing API now supports the same SIWE-based session authentication as the compute API,
8+
* allowing users to sign once and authenticate to both APIs simultaneously.
9+
*/
10+
11+
import { Address, Hex } from "viem";
12+
13+
export interface BillingApiConfig {
14+
/** Base URL of the billing API (e.g., "https://billing.eigencloud.xyz") */
15+
baseUrl: string;
16+
}
17+
18+
export interface BillingSessionInfo {
19+
/** Whether the session is authenticated */
20+
authenticated: boolean;
21+
/** Authenticated wallet address (if authenticated) */
22+
address?: Address;
23+
/** Chain ID used for authentication (if authenticated) */
24+
chainId?: number;
25+
/** Unix timestamp when authentication occurred (if authenticated) */
26+
authenticatedAt?: number;
27+
}
28+
29+
export interface BillingLoginResult {
30+
/** Whether login was successful */
31+
success: boolean;
32+
/** Authenticated wallet address */
33+
address: Address;
34+
}
35+
36+
export interface BillingLoginRequest {
37+
/** SIWE message string */
38+
message: string;
39+
/** Hex-encoded signature (with or without 0x prefix) */
40+
signature: Hex | string;
41+
}
42+
43+
/**
44+
* Error thrown when billing session operations fail
45+
*/
46+
export class BillingSessionError extends Error {
47+
constructor(
48+
message: string,
49+
public readonly code:
50+
| "NETWORK_ERROR"
51+
| "INVALID_SIGNATURE"
52+
| "INVALID_MESSAGE"
53+
| "SESSION_EXPIRED"
54+
| "UNAUTHORIZED"
55+
| "UNKNOWN",
56+
public readonly statusCode?: number,
57+
) {
58+
super(message);
59+
this.name = "BillingSessionError";
60+
}
61+
}
62+
63+
/**
64+
* Strip 0x prefix from hex string if present
65+
*/
66+
function stripHexPrefix(hex: string): string {
67+
return hex.startsWith("0x") ? hex.slice(2) : hex;
68+
}
69+
70+
/**
71+
* Parse error response body
72+
*/
73+
async function parseErrorResponse(response: Response): Promise<string> {
74+
try {
75+
const data = (await response.json()) as { error?: string };
76+
return data.error || response.statusText;
77+
} catch {
78+
return response.statusText;
79+
}
80+
}
81+
82+
/**
83+
* Login to the billing API using SIWE
84+
*
85+
* This establishes a session with the billing API by verifying the SIWE message
86+
* and signature. On success, a session cookie is set in the browser.
87+
*
88+
* The billing API accepts the same SIWE message format as the compute API,
89+
* so users only need to sign once and can send the same message/signature
90+
* to both APIs.
91+
*
92+
* @param config - Billing API configuration
93+
* @param request - Login request containing SIWE message and signature
94+
* @returns Login result with the authenticated address
95+
*
96+
* @example
97+
* ```typescript
98+
* import { createSiweMessage, loginToBillingApi } from "@layr-labs/ecloud-sdk/browser";
99+
*
100+
* const { message } = createSiweMessage({
101+
* address: userAddress,
102+
* chainId: 11155111,
103+
* domain: window.location.host,
104+
* uri: window.location.origin,
105+
* });
106+
*
107+
* const signature = await signMessageAsync({ message });
108+
*
109+
* // Can send to both APIs with the same message/signature
110+
* const [computeResult, billingResult] = await Promise.all([
111+
* loginToComputeApi({ baseUrl: computeApiUrl }, { message, signature }),
112+
* loginToBillingApi({ baseUrl: billingApiUrl }, { message, signature }),
113+
* ]);
114+
* ```
115+
*/
116+
export async function loginToBillingApi(
117+
config: BillingApiConfig,
118+
request: BillingLoginRequest,
119+
): Promise<BillingLoginResult> {
120+
let response: Response;
121+
122+
try {
123+
response = await fetch(`${config.baseUrl}/auth/siwe/login`, {
124+
method: "POST",
125+
credentials: "include", // Include cookies for session management
126+
headers: {
127+
"Content-Type": "application/json",
128+
},
129+
body: JSON.stringify({
130+
message: request.message,
131+
signature: stripHexPrefix(request.signature),
132+
}),
133+
});
134+
} catch (error) {
135+
throw new BillingSessionError(
136+
`Network error connecting to ${config.baseUrl}: ${error instanceof Error ? error.message : String(error)}`,
137+
"NETWORK_ERROR",
138+
);
139+
}
140+
141+
if (!response.ok) {
142+
const errorMessage = await parseErrorResponse(response);
143+
const status = response.status;
144+
145+
if (status === 400) {
146+
if (errorMessage.toLowerCase().includes("siwe")) {
147+
throw new BillingSessionError(`Invalid SIWE message: ${errorMessage}`, "INVALID_MESSAGE", status);
148+
}
149+
throw new BillingSessionError(`Bad request: ${errorMessage}`, "INVALID_MESSAGE", status);
150+
}
151+
152+
if (status === 401) {
153+
throw new BillingSessionError(`Invalid signature: ${errorMessage}`, "INVALID_SIGNATURE", status);
154+
}
155+
156+
throw new BillingSessionError(`Login failed: ${errorMessage}`, "UNKNOWN", status);
157+
}
158+
159+
const data = (await response.json()) as { success: boolean; address: string };
160+
161+
return {
162+
success: data.success,
163+
address: data.address as Address,
164+
};
165+
}
166+
167+
/**
168+
* Get the current session status from the billing API
169+
*
170+
* @param config - Billing API configuration
171+
* @returns Session information including authentication status and address
172+
*
173+
* @example
174+
* ```typescript
175+
* const session = await getBillingApiSession({ baseUrl: "https://billing.eigencloud.xyz" });
176+
* if (session.authenticated) {
177+
* console.log(`Logged in as ${session.address}`);
178+
* }
179+
* ```
180+
*/
181+
export async function getBillingApiSession(config: BillingApiConfig): Promise<BillingSessionInfo> {
182+
let response: Response;
183+
184+
try {
185+
response = await fetch(`${config.baseUrl}/auth/session`, {
186+
method: "GET",
187+
credentials: "include", // Include cookies for session management
188+
headers: {
189+
"Content-Type": "application/json",
190+
},
191+
});
192+
} catch {
193+
// Network error - return unauthenticated session
194+
return {
195+
authenticated: false,
196+
};
197+
}
198+
199+
// If we get a 401, return unauthenticated session
200+
if (response.status === 401) {
201+
return {
202+
authenticated: false,
203+
};
204+
}
205+
206+
if (!response.ok) {
207+
const errorMessage = await parseErrorResponse(response);
208+
throw new BillingSessionError(`Failed to get session: ${errorMessage}`, "UNKNOWN", response.status);
209+
}
210+
211+
const data = (await response.json()) as {
212+
authenticated: boolean;
213+
address?: string;
214+
chainId?: number;
215+
authenticatedAt?: number;
216+
};
217+
218+
return {
219+
authenticated: data.authenticated,
220+
address: data.address as Address | undefined,
221+
chainId: data.chainId,
222+
authenticatedAt: data.authenticatedAt,
223+
};
224+
}
225+
226+
/**
227+
* Logout from the billing API
228+
*
229+
* This destroys the current session and clears the session cookie.
230+
*
231+
* @param config - Billing API configuration
232+
*
233+
* @example
234+
* ```typescript
235+
* await logoutFromBillingApi({ baseUrl: "https://billing.eigencloud.xyz" });
236+
* ```
237+
*/
238+
export async function logoutFromBillingApi(config: BillingApiConfig): Promise<void> {
239+
let response: Response;
240+
241+
try {
242+
response = await fetch(`${config.baseUrl}/auth/logout`, {
243+
method: "POST",
244+
credentials: "include", // Include cookies for session management
245+
headers: {
246+
"Content-Type": "application/json",
247+
},
248+
});
249+
} catch (error) {
250+
throw new BillingSessionError(
251+
`Network error connecting to ${config.baseUrl}: ${error instanceof Error ? error.message : String(error)}`,
252+
"NETWORK_ERROR",
253+
);
254+
}
255+
256+
// Ignore 401 errors during logout (already logged out)
257+
if (response.status === 401) {
258+
return;
259+
}
260+
261+
if (!response.ok) {
262+
const errorMessage = await parseErrorResponse(response);
263+
throw new BillingSessionError(`Logout failed: ${errorMessage}`, "UNKNOWN", response.status);
264+
}
265+
}
266+
267+
/**
268+
* Check if a billing session is still valid (not expired)
269+
*
270+
* This is a convenience function that checks the session status
271+
* and returns a boolean.
272+
*
273+
* @param config - Billing API configuration
274+
* @returns True if session is authenticated, false otherwise
275+
*/
276+
export async function isBillingSessionValid(config: BillingApiConfig): Promise<boolean> {
277+
const session = await getBillingApiSession(config);
278+
return session.authenticated;
279+
}
280+
281+
/**
282+
* Login to both compute and billing APIs simultaneously
283+
*
284+
* This is a convenience function that sends the same SIWE message and signature
285+
* to both APIs in parallel, establishing sessions with both services at once.
286+
*
287+
* @param computeConfig - Compute API configuration
288+
* @param billingConfig - Billing API configuration
289+
* @param request - Login request containing SIWE message and signature
290+
* @returns Object containing login results for both APIs
291+
*
292+
* @example
293+
* ```typescript
294+
* import { createSiweMessage, loginToBothApis } from "@layr-labs/ecloud-sdk/browser";
295+
*
296+
* const { message } = createSiweMessage({
297+
* address: userAddress,
298+
* chainId: 11155111,
299+
* domain: window.location.host,
300+
* uri: window.location.origin,
301+
* });
302+
*
303+
* const signature = await signMessageAsync({ message });
304+
* const { compute, billing } = await loginToBothApis(
305+
* { baseUrl: computeApiUrl },
306+
* { baseUrl: billingApiUrl },
307+
* { message, signature }
308+
* );
309+
* ```
310+
*/
311+
export async function loginToBothApis(
312+
computeConfig: { baseUrl: string },
313+
billingConfig: BillingApiConfig,
314+
request: BillingLoginRequest,
315+
): Promise<{
316+
compute: BillingLoginResult;
317+
billing: BillingLoginResult;
318+
}> {
319+
// Import the compute login function dynamically to avoid circular dependencies
320+
const { loginToComputeApi } = await import("./session");
321+
322+
const [compute, billing] = await Promise.all([
323+
loginToComputeApi(computeConfig, request),
324+
loginToBillingApi(billingConfig, request),
325+
]);
326+
327+
return { compute, billing };
328+
}
329+
330+
/**
331+
* Logout from both compute and billing APIs simultaneously
332+
*
333+
* @param computeConfig - Compute API configuration
334+
* @param billingConfig - Billing API configuration
335+
*
336+
* @example
337+
* ```typescript
338+
* await logoutFromBothApis(
339+
* { baseUrl: computeApiUrl },
340+
* { baseUrl: billingApiUrl }
341+
* );
342+
* ```
343+
*/
344+
export async function logoutFromBothApis(
345+
computeConfig: { baseUrl: string },
346+
billingConfig: BillingApiConfig,
347+
): Promise<void> {
348+
// Import the compute logout function dynamically to avoid circular dependencies
349+
const { logoutFromComputeApi } = await import("./session");
350+
351+
await Promise.all([
352+
logoutFromComputeApi(computeConfig),
353+
logoutFromBillingApi(billingConfig),
354+
]);
355+
}

0 commit comments

Comments
 (0)