diff --git a/src/lib/ai.ts b/src/lib/ai.ts index 3b1866f..7a10808 100644 --- a/src/lib/ai.ts +++ b/src/lib/ai.ts @@ -1,6 +1,7 @@ -import { decryptMessage, encryptMessage } from "./encryption"; -import { getAttestation } from "./getAttestation"; -import * as api from "./api"; +import { decryptMessage, encryptMessage } from './encryption'; +import { getAttestation } from './getAttestation'; +import * as api from './api'; +import { getStorage } from './storage'; export interface CustomFetchOptions { apiKey?: string; // Optional API key to use instead of JWT token @@ -8,9 +9,12 @@ export interface CustomFetchOptions { } export function createCustomFetch( - options?: CustomFetchOptions + options?: CustomFetchOptions, ): (input: string | URL | Request, init?: RequestInit) => Promise { - return async (requestUrl: string | URL | Request, init?: RequestInit): Promise => { + return async ( + requestUrl: string | URL | Request, + init?: RequestInit, + ): Promise => { const getAuthHeader = () => { // If an API key is provided, use it instead of JWT token if (options?.apiKey) { @@ -18,22 +22,26 @@ export function createCustomFetch( } // Otherwise, use the standard JWT token - const currentAccessToken = window.localStorage.getItem("access_token"); + const currentAccessToken = + getStorage().persistent.getItem('access_token'); if (!currentAccessToken) { - throw new Error("No access token or API key available"); + throw new Error('No access token or API key available'); } return `Bearer ${currentAccessToken}`; }; try { const headers = new Headers(init?.headers); - headers.set("Authorization", getAuthHeader()); + headers.set('Authorization', getAuthHeader()); - const { sessionKey, sessionId } = await getAttestation(false, options?.apiUrl); + const { sessionKey, sessionId } = await getAttestation( + false, + options?.apiUrl, + ); if (!sessionKey || !sessionId) { - throw new Error("No session key or ID available"); + throw new Error('No session key or ID available'); } - headers.set("x-session-id", sessionId); + headers.set('x-session-id', sessionId); const requestOptions: RequestInit = { ...init, headers }; @@ -41,16 +49,16 @@ export function createCustomFetch( if (init?.body) { const encryptedBody = encryptMessage(sessionKey, init.body as string); requestOptions.body = JSON.stringify({ encrypted: encryptedBody }); - headers.set("Content-Type", "application/json"); + headers.set('Content-Type', 'application/json'); } let response = await fetch(requestUrl, requestOptions); if (response.status === 401 && !options?.apiKey) { // Only attempt token refresh if we're using JWT auth (not API key) - console.warn("Unauthorized, refreshing access token"); + console.warn('Unauthorized, refreshing access token'); await api.refreshToken(); - headers.set("Authorization", getAuthHeader()); + headers.set('Authorization', getAuthHeader()); requestOptions.headers = headers; response = await fetch(requestUrl, requestOptions); } @@ -58,20 +66,22 @@ export function createCustomFetch( if (!response.ok) { const errorText = await response.text(); console.error( - "Request failed with response status:", + 'Request failed with response status:', response.status, - " and message:", - errorText + ' and message:', + errorText, + ); + throw new Error( + `Request failed with status ${response.status}: ${errorText}`, ); - throw new Error(`Request failed with status ${response.status}: ${errorText}`); } // Decrypt SSE events - if (response.headers.get("content-type")?.includes("text/event-stream")) { + if (response.headers.get('content-type')?.includes('text/event-stream')) { const reader = response.body?.getReader(); const decoder = new TextDecoder(); - let buffer = ""; + let buffer = ''; const stream = new ReadableStream({ async start(controller) { while (true) { @@ -86,17 +96,17 @@ export function createCustomFetch( buffer = buffer.slice(event.length); // Split the event into individual lines - const lines = event.split("\n"); + const lines = event.split('\n'); for (const line of lines) { // Handle event: lines - pass them through as-is - if (line.trim().startsWith("event: ")) { - controller.enqueue(line + "\n"); + if (line.trim().startsWith('event: ')) { + controller.enqueue(line + '\n'); } // Handle data: lines - decrypt them - else if (line.trim().startsWith("data: ")) { + else if (line.trim().startsWith('data: ')) { const data = line.slice(6).trim(); - if (data === "[DONE]") { + if (data === '[DONE]') { controller.enqueue(`data: [DONE]\n\n`); } else { try { @@ -106,27 +116,32 @@ export function createCustomFetch( // Note: We don't add \n\n here because the empty line will be added separately controller.enqueue(`data: ${decrypted}\n`); } catch (error) { - console.error("Decryption error:", error, "Data:", data); + console.error( + 'Decryption error:', + error, + 'Data:', + data, + ); // Instead of sending the encrypted data, we'll skip this chunk - console.log("Skipping corrupted chunk"); + console.log('Skipping corrupted chunk'); } } } // Pass through empty lines - else if (line === "") { - controller.enqueue("\n"); + else if (line === '') { + controller.enqueue('\n'); } } } } controller.close(); - } + }, }); return new Response(stream, { headers: response.headers, status: response.status, - statusText: response.statusText + statusText: response.statusText, }); } @@ -145,7 +160,10 @@ export function createCustomFetch( // Check if this is a TTS response with content_base64 and content_type if (decryptedData.content_base64 && decryptedData.content_type) { - console.log("TTS response detected with content_type:", decryptedData.content_type); + console.log( + 'TTS response detected with content_type:', + decryptedData.content_type, + ); // Decode base64 audio data to binary let bytes: Uint8Array; @@ -156,24 +174,24 @@ export function createCustomFetch( bytes[i] = binaryString.charCodeAt(i); } } catch (e) { - console.error("Failed to decode base64 audio data:", e); - throw new Error("Invalid base64 audio data in TTS response"); + console.error('Failed to decode base64 audio data:', e); + throw new Error('Invalid base64 audio data in TTS response'); } - console.log("Decoded audio bytes length:", bytes.length); + console.log('Decoded audio bytes length:', bytes.length); // Return as a binary response with the proper content type const headersOut = new Headers(response.headers); - headersOut.set("content-type", decryptedData.content_type); + headersOut.set('content-type', decryptedData.content_type); // Remove headers that are no longer valid for the decoded response - headersOut.delete("content-encoding"); - headersOut.delete("content-length"); - headersOut.delete("transfer-encoding"); + headersOut.delete('content-encoding'); + headersOut.delete('content-length'); + headersOut.delete('transfer-encoding'); return new Response(bytes, { headers: headersOut, status: response.status, - statusText: response.statusText + statusText: response.statusText, }); } } catch (jsonError) { @@ -183,29 +201,29 @@ export function createCustomFetch( return new Response(decrypted, { headers: response.headers, status: response.status, - statusText: response.statusText + statusText: response.statusText, }); } } catch (e) { // If it's not JSON or doesn't have encrypted field, return original response - console.log("Response is not encrypted JSON, returning as-is"); + console.log('Response is not encrypted JSON, returning as-is'); } // Return the original response text as a new Response return new Response(responseText, { headers: response.headers, status: response.status, - statusText: response.statusText + statusText: response.statusText, }); } catch (error) { - console.error("Error during fetch process:", error); + console.error('Error during fetch process:', error); throw error; } }; } function extractEvent(buffer: string): string | null { - const eventEnd = buffer.indexOf("\n\n"); + const eventEnd = buffer.indexOf('\n\n'); if (eventEnd === -1) return null; return buffer.slice(0, eventEnd + 2); } diff --git a/src/lib/api.ts b/src/lib/api.ts index a74d573..29e5033 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,7 +1,12 @@ -import { encode } from "@stablelib/base64"; -import { authenticatedApiCall, encryptedApiCall, openAiAuthenticatedApiCall } from "./encryptedApi"; -import type { Model } from "openai/resources/models.js"; -import { getConfig } from "./config"; +import { encode } from '@stablelib/base64'; +import { + authenticatedApiCall, + encryptedApiCall, + openAiAuthenticatedApiCall, +} from './encryptedApi'; +import type { Model } from 'openai/resources/models.js'; +import { getConfig } from './config'; +import { getStorage } from './storage'; export function getApiUrl(): string { return getConfig().apiUrl; @@ -40,37 +45,35 @@ export type KVListItem = { export async function fetchLogin( email: string, - password: string + password: string, ): Promise { const { clientId } = getConfig(); - const response = await encryptedApiCall<{ email: string; password: string; client_id: string }, LoginResponse>( - `${getApiUrl()}/login`, - "POST", - { email, password, client_id: clientId } - ); - + const response = await encryptedApiCall< + { email: string; password: string; client_id: string }, + LoginResponse + >(`${getApiUrl()}/login`, 'POST', { email, password, client_id: clientId }); + // Store tokens automatically - window.localStorage.setItem("access_token", response.access_token); - window.localStorage.setItem("refresh_token", response.refresh_token); - + getStorage().persistent.setItem('access_token', response.access_token); + getStorage().persistent.setItem('refresh_token', response.refresh_token); + return response; } export async function fetchGuestLogin( id: string, - password: string + password: string, ): Promise { const { clientId } = getConfig(); - const response = await encryptedApiCall<{ id: string; password: string; client_id: string }, LoginResponse>( - `${getApiUrl()}/login`, - "POST", - { id, password, client_id: clientId } - ); - + const response = await encryptedApiCall< + { id: string; password: string; client_id: string }, + LoginResponse + >(`${getApiUrl()}/login`, 'POST', { id, password, client_id: clientId }); + // Store tokens automatically - window.localStorage.setItem("access_token", response.access_token); - window.localStorage.setItem("refresh_token", response.refresh_token); - + getStorage().persistent.setItem('access_token', response.access_token); + getStorage().persistent.setItem('refresh_token', response.refresh_token); + return response; } @@ -78,7 +81,7 @@ export async function fetchSignUp( email: string, password: string, inviteCode: string, - name?: string | null + name?: string | null, ): Promise { const { clientId } = getConfig(); const response = await encryptedApiCall< @@ -90,62 +93,65 @@ export async function fetchSignUp( client_id: string; }, LoginResponse - >(`${getApiUrl()}/register`, "POST", { + >(`${getApiUrl()}/register`, 'POST', { email, password, inviteCode: inviteCode.toLowerCase(), client_id: clientId, - name + name, }); - + // Store tokens automatically - window.localStorage.setItem("access_token", response.access_token); - window.localStorage.setItem("refresh_token", response.refresh_token); - + getStorage().persistent.setItem('access_token', response.access_token); + getStorage().persistent.setItem('refresh_token', response.refresh_token); + return response; } export async function fetchGuestSignUp( password: string, - inviteCode: string + inviteCode: string, ): Promise { const { clientId } = getConfig(); const response = await encryptedApiCall< { password: string; inviteCode: string; client_id: string }, LoginResponse - >(`${getApiUrl()}/register`, "POST", { + >(`${getApiUrl()}/register`, 'POST', { password, inviteCode: inviteCode.toLowerCase(), - client_id: clientId + client_id: clientId, }); - + // Store tokens automatically - window.localStorage.setItem("access_token", response.access_token); - window.localStorage.setItem("refresh_token", response.refresh_token); - + getStorage().persistent.setItem('access_token', response.access_token); + getStorage().persistent.setItem('refresh_token', response.refresh_token); + return response; } export async function refreshToken(): Promise { - const refresh_token = window.localStorage.getItem("refresh_token"); - if (!refresh_token) throw new Error("No refresh token available"); + const refresh_token = getStorage().persistent.getItem('refresh_token'); + if (!refresh_token) throw new Error('No refresh token available'); const refreshData = { refresh_token }; try { - const response = await encryptedApiCall( + const response = await encryptedApiCall< + typeof refreshData, + RefreshResponse + >( `${getApiUrl()}/refresh`, - "POST", + 'POST', refreshData, undefined, - "Failed to refresh token" + 'Failed to refresh token', ); - window.localStorage.setItem("access_token", response.access_token); - window.localStorage.setItem("refresh_token", response.refresh_token); + getStorage().persistent.setItem('access_token', response.access_token); + getStorage().persistent.setItem('refresh_token', response.refresh_token); return response; } catch (error) { - console.error("Error refreshing token:", error); + console.error('Error refreshing token:', error); throw error; } } @@ -153,36 +159,36 @@ export async function refreshToken(): Promise { export async function fetchUser(): Promise { return authenticatedApiCall( `${getApiUrl()}/protected/user`, - "GET", + 'GET', undefined, - "Failed to fetch user" + 'Failed to fetch user', ); } export async function fetchPut(key: string, value: string): Promise { return authenticatedApiCall( `${getApiUrl()}/protected/kv/${key}`, - "PUT", + 'PUT', value, - "Failed to put key-value pair" + 'Failed to put key-value pair', ); } export async function fetchDelete(key: string): Promise { return authenticatedApiCall( `${getApiUrl()}/protected/kv/${key}`, - "DELETE", + 'DELETE', undefined, - "Failed to delete key-value pair" + 'Failed to delete key-value pair', ); } export async function fetchDeleteAllKV(): Promise { return authenticatedApiCall( `${getApiUrl()}/protected/kv`, - "DELETE", + 'DELETE', undefined, - "Failed to delete all key-value pairs" + 'Failed to delete all key-value pairs', ); } @@ -190,9 +196,9 @@ export async function fetchGet(key: string): Promise { try { const data = await authenticatedApiCall( `${getApiUrl()}/protected/kv/${key}`, - "GET", + 'GET', undefined, - "Failed to get key-value pair" + 'Failed to get key-value pair', ); return data; } catch (error) { @@ -204,52 +210,56 @@ export async function fetchGet(key: string): Promise { export async function fetchList(): Promise { return authenticatedApiCall( `${getApiUrl()}/protected/kv`, - "GET", + 'GET', undefined, - "Failed to list key-value pairs" + 'Failed to list key-value pairs', ); } export async function fetchLogout(): Promise { - const refresh_token = window.localStorage.getItem("refresh_token"); - + const refresh_token = getStorage().persistent.getItem('refresh_token'); + if (refresh_token) { try { const refreshData = { refresh_token }; - await encryptedApiCall(`${getApiUrl()}/logout`, "POST", refreshData); + await encryptedApiCall( + `${getApiUrl()}/logout`, + 'POST', + refreshData, + ); } catch (error) { - console.error("Error during logout API call:", error); + console.error('Error during logout API call:', error); } } - - localStorage.removeItem("access_token"); - localStorage.removeItem("refresh_token"); - sessionStorage.removeItem("sessionKey"); - sessionStorage.removeItem("sessionId"); + + getStorage().persistent.removeItem('access_token'); + getStorage().persistent.removeItem('refresh_token'); + getStorage().session.removeItem('sessionKey'); + getStorage().session.removeItem('sessionId'); } export async function verifyEmail(code: string): Promise { return encryptedApiCall( `${getApiUrl()}/verify-email/${code}`, - "GET", + 'GET', undefined, undefined, - "Failed to verify email" + 'Failed to verify email', ); } export async function requestNewVerificationCode(): Promise { return authenticatedApiCall( `${getApiUrl()}/protected/request_verification`, - "POST", + 'POST', undefined, - "Failed to request new verification code" + 'Failed to request new verification code', ); } export async function fetchAttestationDocument( nonce: string, - explicitApiUrl?: string + explicitApiUrl?: string, ): Promise { const url = explicitApiUrl || getApiUrl(); const response = await fetch(`${url}/attestation/${nonce}`); @@ -263,19 +273,19 @@ export async function fetchAttestationDocument( export async function keyExchange( clientPublicKey: string, nonce: string, - explicitApiUrl?: string + explicitApiUrl?: string, ): Promise<{ encrypted_session_key: string; session_id: string }> { const url = explicitApiUrl || getApiUrl(); const response = await fetch(`${url}/key_exchange`, { - method: "POST", + method: 'POST', headers: { - "Content-Type": "application/json" + 'Content-Type': 'application/json', }, - body: JSON.stringify({ client_public_key: clientPublicKey, nonce }) + body: JSON.stringify({ client_public_key: clientPublicKey, nonce }), }); if (!response.ok) { - throw new Error("Key exchange failed"); + throw new Error('Key exchange failed'); } return response.json(); @@ -283,20 +293,20 @@ export async function keyExchange( export async function requestPasswordReset( email: string, - hashedSecret: string + hashedSecret: string, ): Promise { const { clientId } = getConfig(); const resetData = { email, hashed_secret: hashedSecret, - client_id: clientId + client_id: clientId, }; return encryptedApiCall( `${getApiUrl()}/password-reset/request`, - "POST", + 'POST', resetData, undefined, - "Failed to request password reset" + 'Failed to request password reset', ); } @@ -304,7 +314,7 @@ export async function confirmPasswordReset( email: string, alphanumericCode: string, plaintextSecret: string, - newPassword: string + newPassword: string, ): Promise { const { clientId } = getConfig(); const confirmData = { @@ -312,46 +322,54 @@ export async function confirmPasswordReset( alphanumeric_code: alphanumericCode, plaintext_secret: plaintextSecret, new_password: newPassword, - client_id: clientId + client_id: clientId, }; return encryptedApiCall( `${getApiUrl()}/password-reset/confirm`, - "POST", + 'POST', confirmData, undefined, - "Failed to confirm password reset" + 'Failed to confirm password reset', ); } -export async function changePassword(currentPassword: string, newPassword: string): Promise { +export async function changePassword( + currentPassword: string, + newPassword: string, +): Promise { const changePasswordData = { current_password: currentPassword, - new_password: newPassword + new_password: newPassword, }; return authenticatedApiCall( `${getApiUrl()}/protected/change_password`, - "POST", + 'POST', changePasswordData, - "Failed to change password" + 'Failed to change password', ); } export async function initiateGitHubAuth( - inviteCode?: string + inviteCode?: string, ): Promise { const { clientId } = getConfig(); try { - return await encryptedApiCall<{ invite_code?: string; client_id: string }, GithubAuthResponse>( + return await encryptedApiCall< + { invite_code?: string; client_id: string }, + GithubAuthResponse + >( `${getApiUrl()}/auth/github`, - "POST", - inviteCode ? { invite_code: inviteCode, client_id: clientId } : { client_id: clientId }, + 'POST', + inviteCode + ? { invite_code: inviteCode, client_id: clientId } + : { client_id: clientId }, undefined, - "Failed to initiate GitHub auth" + 'Failed to initiate GitHub auth', ); } catch (error) { if (error instanceof Error) { - if (error.message.includes("Invalid invite code")) { - throw new Error("Invalid invite code. Please check and try again."); + if (error.message.includes('Invalid invite code')) { + throw new Error('Invalid invite code. Please check and try again.'); } } throw error; @@ -361,41 +379,45 @@ export async function initiateGitHubAuth( export async function handleGitHubCallback( code: string, state: string, - inviteCode: string + inviteCode: string, ): Promise { const callbackData = { code, state, invite_code: inviteCode }; try { const response = await encryptedApiCall( `${getApiUrl()}/auth/github/callback`, - "POST", + 'POST', callbackData, undefined, - "GitHub callback failed" + 'GitHub callback failed', ); - + // Store tokens automatically - window.localStorage.setItem("access_token", response.access_token); - window.localStorage.setItem("refresh_token", response.refresh_token); - + getStorage().persistent.setItem('access_token', response.access_token); + getStorage().persistent.setItem('refresh_token', response.refresh_token); + return response; } catch (error) { - console.error("Detailed GitHub callback error:", error); + console.error('Detailed GitHub callback error:', error); if (error instanceof Error) { if ( - error.message.includes("User exists") || - error.message.includes("Email already registered") + error.message.includes('User exists') || + error.message.includes('Email already registered') ) { throw new Error( - "An account with this email already exists. Please sign in using your existing account." + 'An account with this email already exists. Please sign in using your existing account.', + ); + } else if (error.message.includes('Invalid invite code')) { + throw new Error( + 'Invalid invite code. Please try signing up with a valid invite code.', ); - } else if (error.message.includes("Invalid invite code")) { - throw new Error("Invalid invite code. Please try signing up with a valid invite code."); - } else if (error.message.includes("User not found")) { + } else if (error.message.includes('User not found')) { throw new Error( - "User not found. Please sign up first before attempting to log in with GitHub." + 'User not found. Please sign up first before attempting to log in with GitHub.', ); } else { - throw new Error("Failed to authenticate with GitHub. Please try again."); + throw new Error( + 'Failed to authenticate with GitHub. Please try again.', + ); } } throw error; @@ -423,21 +445,26 @@ export type AppleAuthResponse = { }; export async function initiateGoogleAuth( - inviteCode?: string + inviteCode?: string, ): Promise { const { clientId } = getConfig(); try { - return await encryptedApiCall<{ invite_code?: string; client_id: string }, GoogleAuthResponse>( + return await encryptedApiCall< + { invite_code?: string; client_id: string }, + GoogleAuthResponse + >( `${getApiUrl()}/auth/google`, - "POST", - inviteCode ? { invite_code: inviteCode, client_id: clientId } : { client_id: clientId }, + 'POST', + inviteCode + ? { invite_code: inviteCode, client_id: clientId } + : { client_id: clientId }, undefined, - "Failed to initiate Google auth" + 'Failed to initiate Google auth', ); } catch (error) { if (error instanceof Error) { - if (error.message.includes("Invalid invite code")) { - throw new Error("Invalid invite code. Please check and try again."); + if (error.message.includes('Invalid invite code')) { + throw new Error('Invalid invite code. Please check and try again.'); } } throw error; @@ -447,41 +474,45 @@ export async function initiateGoogleAuth( export async function handleGoogleCallback( code: string, state: string, - inviteCode: string + inviteCode: string, ): Promise { const callbackData = { code, state, invite_code: inviteCode }; try { const response = await encryptedApiCall( `${getApiUrl()}/auth/google/callback`, - "POST", + 'POST', callbackData, undefined, - "Google callback failed" + 'Google callback failed', ); - + // Store tokens automatically - window.localStorage.setItem("access_token", response.access_token); - window.localStorage.setItem("refresh_token", response.refresh_token); - + getStorage().persistent.setItem('access_token', response.access_token); + getStorage().persistent.setItem('refresh_token', response.refresh_token); + return response; } catch (error) { - console.error("Detailed Google callback error:", error); + console.error('Detailed Google callback error:', error); if (error instanceof Error) { if ( - error.message.includes("User exists") || - error.message.includes("Email already registered") + error.message.includes('User exists') || + error.message.includes('Email already registered') ) { throw new Error( - "An account with this email already exists. Please sign in using your existing account." + 'An account with this email already exists. Please sign in using your existing account.', ); - } else if (error.message.includes("Invalid invite code")) { - throw new Error("Invalid invite code. Please try signing up with a valid invite code."); - } else if (error.message.includes("User not found")) { + } else if (error.message.includes('Invalid invite code')) { throw new Error( - "User not found. Please sign up first before attempting to log in with Google." + 'Invalid invite code. Please try signing up with a valid invite code.', + ); + } else if (error.message.includes('User not found')) { + throw new Error( + 'User not found. Please sign up first before attempting to log in with Google.', ); } else { - throw new Error("Failed to authenticate with Google. Please try again."); + throw new Error( + 'Failed to authenticate with Google. Please try again.', + ); } } throw error; @@ -503,21 +534,26 @@ export async function handleGoogleCallback( * The handleAppleCallback function should be used to complete the authentication process. */ export async function initiateAppleAuth( - inviteCode?: string + inviteCode?: string, ): Promise { const { clientId } = getConfig(); try { - return await encryptedApiCall<{ invite_code?: string; client_id: string }, AppleAuthResponse>( + return await encryptedApiCall< + { invite_code?: string; client_id: string }, + AppleAuthResponse + >( `${getApiUrl()}/auth/apple`, - "POST", - inviteCode ? { invite_code: inviteCode, client_id: clientId } : { client_id: clientId }, + 'POST', + inviteCode + ? { invite_code: inviteCode, client_id: clientId } + : { client_id: clientId }, undefined, - "Failed to initiate Apple auth" + 'Failed to initiate Apple auth', ); } catch (error) { if (error instanceof Error) { - if (error.message.includes("Invalid invite code")) { - throw new Error("Invalid invite code. Please check and try again."); + if (error.message.includes('Invalid invite code')) { + throw new Error('Invalid invite code. Please check and try again.'); } } throw error; @@ -542,41 +578,43 @@ export async function initiateAppleAuth( export async function handleAppleCallback( code: string, state: string, - inviteCode: string + inviteCode: string, ): Promise { const callbackData = { code, state, invite_code: inviteCode }; try { const response = await encryptedApiCall( `${getApiUrl()}/auth/apple/callback`, - "POST", + 'POST', callbackData, undefined, - "Apple callback failed" + 'Apple callback failed', ); - + // Store tokens automatically - window.localStorage.setItem("access_token", response.access_token); - window.localStorage.setItem("refresh_token", response.refresh_token); - + getStorage().persistent.setItem('access_token', response.access_token); + getStorage().persistent.setItem('refresh_token', response.refresh_token); + return response; } catch (error) { - console.error("Detailed Apple callback error:", error); + console.error('Detailed Apple callback error:', error); if (error instanceof Error) { if ( - error.message.includes("User exists") || - error.message.includes("Email already registered") + error.message.includes('User exists') || + error.message.includes('Email already registered') ) { throw new Error( - "An account with this email already exists. Please sign in using your existing account." + 'An account with this email already exists. Please sign in using your existing account.', + ); + } else if (error.message.includes('Invalid invite code')) { + throw new Error( + 'Invalid invite code. Please try signing up with a valid invite code.', ); - } else if (error.message.includes("Invalid invite code")) { - throw new Error("Invalid invite code. Please try signing up with a valid invite code."); - } else if (error.message.includes("User not found")) { + } else if (error.message.includes('User not found')) { throw new Error( - "User not found. Please sign up first before attempting to log in with Apple." + 'User not found. Please sign up first before attempting to log in with Apple.', ); } else { - throw new Error("Failed to authenticate with Apple. Please try again."); + throw new Error('Failed to authenticate with Apple. Please try again.'); } } throw error; @@ -626,50 +664,54 @@ export type AppleUser = { */ export async function handleAppleNativeSignIn( appleUser: AppleUser, - inviteCode?: string + inviteCode?: string, ): Promise { const { clientId } = getConfig(); // Combine the Apple user data with our app's client ID const signInData = { ...appleUser, client_id: clientId, - ...(inviteCode ? { invite_code: inviteCode } : {}) + ...(inviteCode ? { invite_code: inviteCode } : {}), }; try { const response = await encryptedApiCall( `${getApiUrl()}/auth/apple/native`, - "POST", + 'POST', signInData, undefined, - "Apple Sign-In failed" + 'Apple Sign-In failed', ); - + // Store tokens automatically - window.localStorage.setItem("access_token", response.access_token); - window.localStorage.setItem("refresh_token", response.refresh_token); - + getStorage().persistent.setItem('access_token', response.access_token); + getStorage().persistent.setItem('refresh_token', response.refresh_token); + return response; } catch (error) { - console.error("Detailed Apple Sign-In error:", error); + console.error('Detailed Apple Sign-In error:', error); if (error instanceof Error) { if ( - error.message.includes("User exists") || - error.message.includes("Email already registered") + error.message.includes('User exists') || + error.message.includes('Email already registered') ) { throw new Error( - "An account with this email already exists. Please sign in using your existing account." + 'An account with this email already exists. Please sign in using your existing account.', + ); + } else if (error.message.includes('Invalid invite code')) { + throw new Error( + 'Invalid invite code. Please try signing up with a valid invite code.', ); - } else if (error.message.includes("Invalid invite code")) { - throw new Error("Invalid invite code. Please try signing up with a valid invite code."); - } else if (error.message.includes("User not found")) { + } else if (error.message.includes('User not found')) { throw new Error( - "User not found. Please sign up first before attempting to log in with Apple." + 'User not found. Please sign up first before attempting to log in with Apple.', + ); + } else if (error.message.includes('No email found')) { + throw new Error( + 'Unable to retrieve email from Apple. Please try another sign-in method.', ); - } else if (error.message.includes("No email found")) { - throw new Error("Unable to retrieve email from Apple. Please try another sign-in method."); } else { - throw new Error("Failed to authenticate with Apple. Please try again."); + throw new Error('Failed to authenticate with Apple. Please try again.'); } } throw error; @@ -716,7 +758,9 @@ export type KeyOptions = { * - Common BIP-85 path format: m/83696968'/39'/0'/[entropy in bits]'/[index]' * where entropy is typically 12' for 12-word mnemonics */ -export async function fetchPrivateKey(key_options?: KeyOptions): Promise { +export async function fetchPrivateKey( + key_options?: KeyOptions, +): Promise { // Build URL with query parameters let url = `${getApiUrl()}/protected/private_key`; const queryParams = []; @@ -724,27 +768,27 @@ export async function fetchPrivateKey(key_options?: KeyOptions): Promise 0) { - url += `?${queryParams.join("&")}`; + url += `?${queryParams.join('&')}`; } return authenticatedApiCall( url, - "GET", + 'GET', undefined, - "Failed to fetch private key" + 'Failed to fetch private key', ); } @@ -783,7 +827,7 @@ export async function fetchPrivateKey(key_options?: KeyOptions): Promise { // Build URL with query parameters let url = `${getApiUrl()}/protected/private_key_bytes`; @@ -792,27 +836,27 @@ export async function fetchPrivateKeyBytes( // Add seed phrase derivation path if present if (key_options?.seed_phrase_derivation_path) { queryParams.push( - `seed_phrase_derivation_path=${encodeURIComponent(key_options.seed_phrase_derivation_path)}` + `seed_phrase_derivation_path=${encodeURIComponent(key_options.seed_phrase_derivation_path)}`, ); } // Add private key derivation path if present if (key_options?.private_key_derivation_path) { queryParams.push( - `private_key_derivation_path=${encodeURIComponent(key_options.private_key_derivation_path)}` + `private_key_derivation_path=${encodeURIComponent(key_options.private_key_derivation_path)}`, ); } // Append query parameters if any exist if (queryParams.length > 0) { - url += `?${queryParams.join("&")}`; + url += `?${queryParams.join('&')}`; } return authenticatedApiCall( url, - "GET", + 'GET', undefined, - "Failed to fetch private key bytes" + 'Failed to fetch private key bytes', ); } @@ -823,7 +867,7 @@ export type SignMessageResponse = { message_hash: string; }; -type SigningAlgorithm = "schnorr" | "ecdsa"; +type SigningAlgorithm = 'schnorr' | 'ecdsa'; export type SignMessageRequest = { /** Base64-encoded message to sign */ @@ -874,21 +918,21 @@ export type SignMessageRequest = { export async function signMessage( message_bytes: Uint8Array, algorithm: SigningAlgorithm, - key_options?: KeyOptions + key_options?: KeyOptions, ): Promise { const message_base64 = encode(message_bytes); const requestData = { message_base64, algorithm, - ...(key_options && Object.keys(key_options).length > 0 && { key_options }) + ...(key_options && Object.keys(key_options).length > 0 && { key_options }), }; return authenticatedApiCall( `${getApiUrl()}/protected/sign_message`, - "POST", + 'POST', requestData, - "Failed to sign message" + 'Failed to sign message', ); } @@ -928,7 +972,7 @@ export type PublicKeyResponse = { */ export async function fetchPublicKey( algorithm: SigningAlgorithm, - key_options?: KeyOptions + key_options?: KeyOptions, ): Promise { // Build URL with query parameters let url = `${getApiUrl()}/protected/public_key?algorithm=${algorithm}`; @@ -945,28 +989,28 @@ export async function fetchPublicKey( return authenticatedApiCall( url, - "GET", + 'GET', undefined, - "Failed to fetch public key" + 'Failed to fetch public key', ); } export async function convertGuestToEmailAccount( email: string, password: string, - name?: string | null + name?: string | null, ): Promise { const conversionData = { email, password, - ...(name !== undefined && { name }) + ...(name !== undefined && { name }), }; return authenticatedApiCall( `${getApiUrl()}/protected/convert_guest`, - "POST", + 'POST', conversionData, - "Failed to convert guest account" + 'Failed to convert guest account', ); } @@ -987,12 +1031,14 @@ export type ThirdPartyTokenResponse = { * - If audience is provided, it can be any valid URL * - If audience is omitted, a token with no audience restriction will be generated */ -export async function generateThirdPartyToken(audience?: string): Promise { +export async function generateThirdPartyToken( + audience?: string, +): Promise { return authenticatedApiCall( `${getApiUrl()}/protected/third_party_token`, - "POST", + 'POST', audience ? { audience } : {}, - "Failed to generate third party token" + 'Failed to generate third party token', ); } @@ -1043,18 +1089,18 @@ export type EncryptDataResponse = { */ export async function encryptData( data: string, - key_options?: KeyOptions + key_options?: KeyOptions, ): Promise { const requestData = { data, - ...(key_options && Object.keys(key_options).length > 0 && { key_options }) + ...(key_options && Object.keys(key_options).length > 0 && { key_options }), }; return authenticatedApiCall( `${getApiUrl()}/protected/encrypt`, - "POST", + 'POST', requestData, - "Failed to encrypt data" + 'Failed to encrypt data', ); } @@ -1102,18 +1148,18 @@ export type DecryptDataRequest = { */ export async function decryptData( encryptedData: string, - key_options?: KeyOptions + key_options?: KeyOptions, ): Promise { const requestData = { encrypted_data: encryptedData, - ...(key_options && Object.keys(key_options).length > 0 && { key_options }) + ...(key_options && Object.keys(key_options).length > 0 && { key_options }), }; return authenticatedApiCall( `${getApiUrl()}/protected/decrypt`, - "POST", + 'POST', requestData, - "Failed to decrypt data" + 'Failed to decrypt data', ); } @@ -1129,15 +1175,17 @@ export async function decryptData( * 3. The email contains a confirmation code that will be needed for confirmation * 4. The client must store the plaintext secret for confirmation */ -export async function requestAccountDeletion(hashedSecret: string): Promise { +export async function requestAccountDeletion( + hashedSecret: string, +): Promise { const deleteData = { - hashed_secret: hashedSecret + hashed_secret: hashedSecret, }; return authenticatedApiCall( `${getApiUrl()}/protected/delete-account/request`, - "POST", + 'POST', deleteData, - "Failed to request account deletion" + 'Failed to request account deletion', ); } @@ -1156,22 +1204,22 @@ export async function requestAccountDeletion(hashedSecret: string): Promise { const confirmData = { confirmation_code: confirmationCode, - plaintext_secret: plaintextSecret + plaintext_secret: plaintextSecret, }; return authenticatedApiCall( `${getApiUrl()}/protected/delete-account/confirm`, - "POST", + 'POST', confirmData, - "Failed to confirm account deletion" + 'Failed to confirm account deletion', ); } type ModelsListResponse = { - object: "list"; + object: 'list'; data: Model[]; }; @@ -1188,24 +1236,26 @@ export async function fetchModels(apiKey?: string): Promise { try { const response = await openAiAuthenticatedApiCall( `${getApiUrl()}/v1/models`, - "GET", + 'GET', undefined, - "Failed to fetch models", - apiKey + 'Failed to fetch models', + apiKey, ); // Validate response structure - if (!response || typeof response !== "object") { - throw new Error("Invalid response from models endpoint"); + if (!response || typeof response !== 'object') { + throw new Error('Invalid response from models endpoint'); } - if (response.object !== "list" || !Array.isArray(response.data)) { - throw new Error("Models response missing expected 'object' or 'data' fields"); + if (response.object !== 'list' || !Array.isArray(response.data)) { + throw new Error( + "Models response missing expected 'object' or 'data' fields", + ); } return response.data; } catch (error) { - console.error("Error fetching models:", error); + console.error('Error fetching models:', error); throw error; } } @@ -1280,12 +1330,14 @@ function delay(ms: number): Promise { * // Save this key securely - it won't be shown again! * ``` */ -export async function createApiKey(name: string): Promise { +export async function createApiKey( + name: string, +): Promise { return authenticatedApiCall<{ name: string }, ApiKeyCreateResponse>( `${getApiUrl()}/protected/api-keys`, - "POST", + 'POST', { name }, - "Failed to create API key" + 'Failed to create API key', ); } @@ -1310,15 +1362,21 @@ export async function createApiKey(name: string): Promise * ``` */ export async function listApiKeys(): Promise<{ keys: ApiKeyListResponse }> { - const response = await authenticatedApiCall( + const response = await authenticatedApiCall< + void, + { keys: ApiKeyListResponse } + >( `${getApiUrl()}/protected/api-keys`, - "GET", + 'GET', undefined, - "Failed to list API keys" + 'Failed to list API keys', ); // Sort by created_at descending (newest first) - response.keys.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()); + response.keys.sort( + (a, b) => + new Date(b.created_at).getTime() - new Date(a.created_at).getTime(), + ); return response; } @@ -1349,9 +1407,9 @@ export async function deleteApiKey(name: string): Promise { const encodedName = encodeURIComponent(name); return authenticatedApiCall( `${getApiUrl()}/protected/api-keys/${encodedName}`, - "DELETE", + 'DELETE', undefined, - "Failed to delete API key" + 'Failed to delete API key', ); } @@ -1382,10 +1440,14 @@ export async function deleteApiKey(name: string): Promise { * console.log(result.task_id); // Task ID to check status * ``` */ -export async function uploadDocument(file: File | Blob): Promise { +export async function uploadDocument( + file: File | Blob, +): Promise { // Validate file size if (file.size > MAX_FILE_SIZE) { - throw new Error(`File size exceeds maximum limit of ${MAX_FILE_SIZE / 1024 / 1024}MB`); + throw new Error( + `File size exceeds maximum limit of ${MAX_FILE_SIZE / 1024 / 1024}MB`, + ); } // Convert file to base64 @@ -1394,18 +1456,21 @@ export async function uploadDocument(file: File | Blob): Promise( + return authenticatedApiCall< + DocumentUploadRequest, + DocumentUploadInitResponse + >( `${getApiUrl()}/v1/documents/upload`, - "POST", + 'POST', requestData, - "Failed to upload document" + 'Failed to upload document', ); } @@ -1434,16 +1499,18 @@ export async function uploadDocument(file: File | Blob): Promise { +export async function checkDocumentStatus( + taskId: string, +): Promise { const requestData: DocumentStatusRequest = { - task_id: taskId + task_id: taskId, }; return authenticatedApiCall( `${getApiUrl()}/v1/documents/status`, - "POST", + 'POST', requestData, - "Failed to check document status" + 'Failed to check document status', ); } @@ -1484,7 +1551,7 @@ export async function uploadDocumentWithPolling( pollInterval?: number; // milliseconds, default 2000 maxAttempts?: number; // default 150 (5 minutes with 2s interval) onProgress?: (status: string, progress?: number) => void; - } + }, ): Promise { const { pollInterval = 2000, maxAttempts = 150, onProgress } = options || {}; @@ -1501,17 +1568,19 @@ export async function uploadDocumentWithPolling( } switch (statusResponse.status) { - case "success": + case 'success': if (!statusResponse.document) { - throw new Error("Document processing succeeded but no document returned"); + throw new Error( + 'Document processing succeeded but no document returned', + ); } return statusResponse.document; - case "failure": - throw new Error(statusResponse.error || "Document processing failed"); + case 'failure': + throw new Error(statusResponse.error || 'Document processing failed'); - case "pending": - case "started": + case 'pending': + case 'started': // Continue polling await delay(pollInterval); attempts++; @@ -1522,7 +1591,7 @@ export async function uploadDocumentWithPolling( } } - throw new Error("Document processing timed out"); + throw new Error('Document processing timed out'); } export type WhisperTranscriptionRequest = { @@ -1579,7 +1648,7 @@ export async function transcribeAudio( language?: string, prompt?: string, temperature?: number, - apiKey?: string + apiKey?: string, ): Promise { // Convert file to base64 const arrayBuffer = await file.arrayBuffer(); @@ -1587,34 +1656,37 @@ export async function transcribeAudio( const base64Data = encode(bytes); // Get filename and content type - const filename = file instanceof File ? file.name : "audio"; - const contentType = file.type || "audio/mpeg"; + const filename = file instanceof File ? file.name : 'audio'; + const contentType = file.type || 'audio/mpeg'; const requestData: WhisperTranscriptionRequest = { file: base64Data, filename, content_type: contentType, - model: model || "whisper-large-v3", + model: model || 'whisper-large-v3', ...(language && { language }), ...(prompt && { prompt }), - ...(temperature !== undefined && { temperature }) + ...(temperature !== undefined && { temperature }), }; // Use openAiAuthenticatedApiCall to support both JWT and API key auth - return openAiAuthenticatedApiCall( + return openAiAuthenticatedApiCall< + WhisperTranscriptionRequest, + WhisperTranscriptionResponse + >( `${getApiUrl()}/v1/audio/transcriptions`, - "POST", + 'POST', requestData, - "Failed to transcribe audio", - apiKey + 'Failed to transcribe audio', + apiKey, ); } export type ResponsesRetrieveResponse = { id: string; - object: "response"; + object: 'response'; created_at: number; - status: "queued" | "in_progress" | "completed" | "failed" | "cancelled"; + status: 'queued' | 'in_progress' | 'completed' | 'failed' | 'cancelled'; model: string; usage?: { input_tokens: number; @@ -1632,14 +1704,14 @@ export type ResponsesRetrieveResponse = { export type ThreadListItem = { id: string; - object: "thread"; + object: 'thread'; created_at: number; updated_at: number; title: string; }; export type ResponsesListResponse = { - object: "list"; + object: 'list'; data: ThreadListItem[]; has_more: boolean; first_id?: string; @@ -1655,11 +1727,11 @@ export type ResponsesListParams = { export type ConversationItem = { id: string; - type: "message"; - status: "completed" | "in_progress" | "incomplete"; - role: "user" | "assistant" | "system"; + type: 'message'; + status: 'completed' | 'in_progress' | 'incomplete'; + role: 'user' | 'assistant' | 'system'; content: Array<{ - type: "text" | "input_text" | "input_audio" | "item"; + type: 'text' | 'input_text' | 'input_audio' | 'item'; text?: string; audio?: string; transcript?: string; @@ -1669,7 +1741,7 @@ export type ConversationItem = { export type Conversation = { id: string; - object: "conversation"; + object: 'conversation'; created_at: number; metadata?: Record; }; @@ -1683,7 +1755,7 @@ export type ConversationUpdateRequest = { }; export type ConversationItemsResponse = { - object: "list"; + object: 'list'; data: ConversationItem[]; first_id?: string; last_id?: string; @@ -1691,7 +1763,7 @@ export type ConversationItemsResponse = { }; export type ConversationsListResponse = { - object: "list"; + object: 'list'; data: Conversation[]; first_id?: string; last_id?: string; @@ -1700,12 +1772,12 @@ export type ConversationsListResponse = { export type ConversationDeleteResponse = { id: string; - object: "conversation.deleted"; + object: 'conversation.deleted'; deleted: boolean; }; export type ConversationsDeleteResponse = { - object: "list.deleted"; + object: 'list.deleted'; deleted: boolean; }; @@ -1715,13 +1787,13 @@ export type BatchDeleteConversationsRequest = { export type BatchDeleteItemResult = { id: string; - object: "conversation.deleted"; + object: 'conversation.deleted'; deleted: boolean; - error?: "not_found" | "delete_failed"; + error?: 'not_found' | 'delete_failed'; }; export type BatchDeleteConversationsResponse = { - object: "list"; + object: 'list'; data: BatchDeleteItemResult[]; }; @@ -1764,7 +1836,7 @@ export type BatchDeleteConversationsResponse = { * ``` */ export async function fetchResponsesList( - params?: ResponsesListParams + params?: ResponsesListParams, ): Promise { let url = `${getApiUrl()}/v1/responses`; const queryParams = []; @@ -1783,14 +1855,14 @@ export async function fetchResponsesList( } if (queryParams.length > 0) { - url += `?${queryParams.join("&")}`; + url += `?${queryParams.join('&')}`; } return authenticatedApiCall( url, - "GET", + 'GET', undefined, - "Failed to list responses" + 'Failed to list responses', ); } @@ -1814,20 +1886,22 @@ export async function fetchResponsesList( * console.log(response.usage); // Token usage statistics * ``` */ -export async function fetchResponse(responseId: string): Promise { +export async function fetchResponse( + responseId: string, +): Promise { return authenticatedApiCall( `${getApiUrl()}/v1/responses/${encodeURIComponent(responseId)}`, - "GET", + 'GET', undefined, - "Failed to retrieve response" + 'Failed to retrieve response', ); } export type ResponsesCancelResponse = { id: string; - object: "response"; + object: 'response'; created_at: number; - status: "cancelled"; + status: 'cancelled'; model: string; }; @@ -1855,18 +1929,20 @@ export type ResponsesCancelResponse = { * } * ``` */ -export async function cancelResponse(responseId: string): Promise { +export async function cancelResponse( + responseId: string, +): Promise { return authenticatedApiCall( `${getApiUrl()}/v1/responses/${encodeURIComponent(responseId)}/cancel`, - "POST", + 'POST', undefined, - "Failed to cancel response" + 'Failed to cancel response', ); } export type ResponsesDeleteResponse = { id: string; - object: "response.deleted"; + object: 'response.deleted'; deleted: boolean; }; @@ -1902,14 +1978,16 @@ export type ResponsesCreateRequest = { * * @deprecated Use openai.conversations.create() instead */ -export async function createConversation(metadata?: Record): Promise { +export async function createConversation( + metadata?: Record, +): Promise { const requestData: ConversationCreateRequest = metadata ? { metadata } : {}; return authenticatedApiCall( `${getApiUrl()}/v1/conversations`, - "POST", + 'POST', requestData, - "Failed to create conversation" + 'Failed to create conversation', ); } @@ -1929,12 +2007,14 @@ export async function createConversation(metadata?: Record): Promis * console.log(conversation.metadata); * ``` */ -export async function getConversation(conversationId: string): Promise { +export async function getConversation( + conversationId: string, +): Promise { return authenticatedApiCall( `${getApiUrl()}/v1/conversations/${encodeURIComponent(conversationId)}`, - "GET", + 'GET', undefined, - "Failed to retrieve conversation" + 'Failed to retrieve conversation', ); } @@ -1958,15 +2038,15 @@ export async function getConversation(conversationId: string): Promise + metadata: Record, ): Promise { const requestData: ConversationUpdateRequest = { metadata }; return authenticatedApiCall( `${getApiUrl()}/v1/conversations/${encodeURIComponent(conversationId)}`, - "POST", + 'POST', requestData, - "Failed to update conversation" + 'Failed to update conversation', ); } @@ -1993,13 +2073,13 @@ export async function updateConversation( * ``` */ export async function deleteConversation( - conversationId: string + conversationId: string, ): Promise { return authenticatedApiCall( `${getApiUrl()}/v1/conversations/${encodeURIComponent(conversationId)}`, - "DELETE", + 'DELETE', undefined, - "Failed to delete conversation" + 'Failed to delete conversation', ); } @@ -2025,9 +2105,9 @@ export async function deleteConversation( export async function deleteConversations(): Promise { return authenticatedApiCall( `${getApiUrl()}/v1/conversations`, - "DELETE", + 'DELETE', undefined, - "Failed to delete conversations" + 'Failed to delete conversations', ); } @@ -2059,13 +2139,16 @@ export async function deleteConversations(): Promise { - return authenticatedApiCall( + return authenticatedApiCall< + BatchDeleteConversationsRequest, + BatchDeleteConversationsResponse + >( `${getApiUrl()}/v1/conversations/batch-delete`, - "POST", + 'POST', { ids }, - "Failed to batch delete conversations" + 'Failed to batch delete conversations', ); } @@ -2093,13 +2176,16 @@ export async function batchDeleteConversations( */ export async function addConversationItems( conversationId: string, - items: Partial[] + items: Partial[], ): Promise { - return authenticatedApiCall<{ items: Partial[] }, Conversation>( + return authenticatedApiCall< + { items: Partial[] }, + Conversation + >( `${getApiUrl()}/v1/conversations/${encodeURIComponent(conversationId)}/items`, - "POST", + 'POST', { items }, - "Failed to add conversation items" + 'Failed to add conversation items', ); } @@ -2130,7 +2216,7 @@ export async function listConversationItems( limit?: number; after?: string; before?: string; - } + }, ): Promise { let url = `${getApiUrl()}/v1/conversations/${encodeURIComponent(conversationId)}/items`; const queryParams = []; @@ -2146,14 +2232,14 @@ export async function listConversationItems( } if (queryParams.length > 0) { - url += `?${queryParams.join("&")}`; + url += `?${queryParams.join('&')}`; } return authenticatedApiCall( url, - "GET", + 'GET', undefined, - "Failed to list conversation items" + 'Failed to list conversation items', ); } @@ -2197,14 +2283,14 @@ export async function listConversations(params?: { } if (queryParams.length > 0) { - url += `?${queryParams.join("&")}`; + url += `?${queryParams.join('&')}`; } return authenticatedApiCall( url, - "GET", + 'GET', undefined, - "Failed to list conversations" + 'Failed to list conversations', ); } @@ -2238,12 +2324,14 @@ export async function listConversations(params?: { * }); * ``` */ -export async function createResponse(request: ResponsesCreateRequest): Promise { +export async function createResponse( + request: ResponsesCreateRequest, +): Promise { return authenticatedApiCall( `${getApiUrl()}/v1/responses`, - "POST", + 'POST', request, - "Failed to create response" + 'Failed to create response', ); } @@ -2268,18 +2356,20 @@ export async function createResponse(request: ResponsesCreateRequest): Promise { +export async function deleteResponse( + responseId: string, +): Promise { return authenticatedApiCall( `${getApiUrl()}/v1/responses/${encodeURIComponent(responseId)}`, - "DELETE", + 'DELETE', undefined, - "Failed to delete response" + 'Failed to delete response', ); } export type Instruction = { id: string; - object: "instruction"; + object: 'instruction'; name: string; prompt: string; prompt_tokens: number; @@ -2308,7 +2398,7 @@ export type InstructionListParams = { }; export type InstructionListResponse = { - object: "list"; + object: 'list'; data: Instruction[]; has_more: boolean; first_id: string | null; @@ -2317,7 +2407,7 @@ export type InstructionListResponse = { export type InstructionDeleteResponse = { id: string; - object: "instruction.deleted"; + object: 'instruction.deleted'; deleted: true; }; @@ -2343,12 +2433,14 @@ export type InstructionDeleteResponse = { * }); * ``` */ -export async function createInstruction(request: InstructionCreateRequest): Promise { +export async function createInstruction( + request: InstructionCreateRequest, +): Promise { return authenticatedApiCall( `${getApiUrl()}/v1/instructions`, - "POST", + 'POST', request, - "Failed to create instruction" + 'Failed to create instruction', ); } @@ -2381,7 +2473,7 @@ export async function createInstruction(request: InstructionCreateRequest): Prom * ``` */ export async function listInstructions( - params?: InstructionListParams + params?: InstructionListParams, ): Promise { let url = `${getApiUrl()}/v1/instructions`; const queryParams = []; @@ -2400,14 +2492,14 @@ export async function listInstructions( } if (queryParams.length > 0) { - url += `?${queryParams.join("&")}`; + url += `?${queryParams.join('&')}`; } return authenticatedApiCall( url, - "GET", + 'GET', undefined, - "Failed to list instructions" + 'Failed to list instructions', ); } @@ -2425,12 +2517,14 @@ export async function listInstructions( * console.log(instruction.name, instruction.prompt); * ``` */ -export async function getInstruction(instructionId: string): Promise { +export async function getInstruction( + instructionId: string, +): Promise { return authenticatedApiCall( `${getApiUrl()}/v1/instructions/${encodeURIComponent(instructionId)}`, - "GET", + 'GET', undefined, - "Failed to retrieve instruction" + 'Failed to retrieve instruction', ); } @@ -2459,13 +2553,13 @@ export async function getInstruction(instructionId: string): Promise { return authenticatedApiCall( `${getApiUrl()}/v1/instructions/${encodeURIComponent(instructionId)}`, - "POST", + 'POST', request, - "Failed to update instruction" + 'Failed to update instruction', ); } @@ -2489,12 +2583,14 @@ export async function updateInstruction( * } * ``` */ -export async function deleteInstruction(instructionId: string): Promise { +export async function deleteInstruction( + instructionId: string, +): Promise { return authenticatedApiCall( `${getApiUrl()}/v1/instructions/${encodeURIComponent(instructionId)}`, - "DELETE", + 'DELETE', undefined, - "Failed to delete instruction" + 'Failed to delete instruction', ); } @@ -2516,11 +2612,13 @@ export async function deleteInstruction(instructionId: string): Promise { +export async function setDefaultInstruction( + instructionId: string, +): Promise { return authenticatedApiCall( `${getApiUrl()}/v1/instructions/${encodeURIComponent(instructionId)}/set-default`, - "POST", + 'POST', undefined, - "Failed to set default instruction" + 'Failed to set default instruction', ); } diff --git a/src/lib/config.ts b/src/lib/config.ts index ef2c6b0..9b96005 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -2,9 +2,14 @@ * Global configuration for OpenSecret SDK */ +import type { StorageProvider } from './storage'; +import { setStorageProvider, resetStorage } from './storage'; + export interface OpenSecretConfig { apiUrl: string; clientId: string; + /** Custom storage provider for non-browser environments (React Native, Node, etc.) */ + storage?: StorageProvider; } let config: OpenSecretConfig | null = null; @@ -12,15 +17,15 @@ let config: OpenSecretConfig | null = null; /** * Configure the OpenSecret SDK with your API URL and client ID. * This must be called before using any other SDK functions. - * + * * @param options - Configuration options * @param options.apiUrl - The URL of your OpenSecret backend * @param options.clientId - Your project's client ID (UUID) - * + * * @example * ```typescript * import { configure } from '@opensecret/react'; - * + * * configure({ * apiUrl: 'https://api.opensecret.cloud', * clientId: '550e8400-e29b-41d4-a716-446655440000' @@ -35,9 +40,13 @@ export function configure(options: OpenSecretConfig): void { throw new Error('OpenSecret SDK requires a non-empty clientId'); } + if (options.storage) { + setStorageProvider(options.storage); + } + config = { apiUrl: options.apiUrl.replace(/\/$/, ''), // Remove trailing slash - clientId: options.clientId + clientId: options.clientId, }; } @@ -48,7 +57,7 @@ export function configure(options: OpenSecretConfig): void { export function getConfig(): OpenSecretConfig { if (!config) { throw new Error( - 'OpenSecret SDK not configured. Please call configure() with your apiUrl and clientId first.' + 'OpenSecret SDK not configured. Please call configure() with your apiUrl and clientId first.', ); } return config; @@ -66,4 +75,5 @@ export function isConfigured(): boolean { */ export function resetConfig(): void { config = null; + resetStorage(); } diff --git a/src/lib/developer.tsx b/src/lib/developer.tsx index 3d524f0..140bcd6 100644 --- a/src/lib/developer.tsx +++ b/src/lib/developer.tsx @@ -1,17 +1,18 @@ -import React, { createContext, useState, useEffect } from "react"; -import * as platformApi from "./platformApi"; -import { setPlatformApiUrl } from "./platformApi"; -import { apiConfig } from "./apiConfig"; -import { getAttestation } from "./getAttestation"; -import { authenticate } from "./attestation"; +import React, { createContext, useState, useEffect } from 'react'; +import * as platformApi from './platformApi'; +import { setPlatformApiUrl } from './platformApi'; +import { apiConfig } from './apiConfig'; +import { getAttestation } from './getAttestation'; +import { getStorage } from './storage'; +import { authenticate } from './attestation'; import { parseAttestationForView, AWS_ROOT_CERT_DER, EXPECTED_ROOT_CERT_HASH, - ParsedAttestationView -} from "./attestationForView"; -import type { AttestationDocument } from "./attestation"; -import { PcrConfig } from "./pcr"; + ParsedAttestationView, +} from './attestationForView'; +import type { AttestationDocument } from './attestation'; +import { PcrConfig } from './pcr'; import type { Organization, Project, @@ -22,10 +23,10 @@ import type { OrganizationMember, PlatformOrg, PlatformUser, - OrganizationInvite -} from "./platformApi"; + OrganizationInvite, +} from './platformApi'; -export type DeveloperRole = "owner" | "admin" | "developer" | "viewer"; +export type DeveloperRole = 'owner' | 'admin' | 'developer' | 'viewer'; export type OrganizationDetails = Organization; @@ -55,7 +56,10 @@ export type OpenSecretDeveloperContextType = { * - Updates the developer state with user information * - Throws an error if authentication fails */ - signIn: (email: string, password: string) => Promise; + signIn: ( + email: string, + password: string, + ) => Promise; /** * Verifies a platform user's email using the verification code @@ -151,7 +155,7 @@ export type OpenSecretDeveloperContextType = { email: string, password: string, invite_code: string, - name?: string + name?: string, ) => Promise; /** @@ -197,7 +201,7 @@ export type OpenSecretDeveloperContextType = { parseAttestationForView: ( document: AttestationDocument, cabundle: Uint8Array[], - pcrConfig?: PcrConfig + pcrConfig?: PcrConfig, ) => Promise; /** @@ -249,7 +253,11 @@ export type OpenSecretDeveloperContextType = { * @param description - Optional project description * @returns A promise that resolves to the project details including client ID */ - createProject: (orgId: string, name: string, description?: string) => Promise; + createProject: ( + orgId: string, + name: string, + description?: string, + ) => Promise; /** * Lists all projects within an organization @@ -275,7 +283,7 @@ export type OpenSecretDeveloperContextType = { updateProject: ( orgId: string, projectId: string, - updates: { name?: string; description?: string; status?: string } + updates: { name?: string; description?: string; status?: string }, ) => Promise; /** @@ -306,7 +314,7 @@ export type OpenSecretDeveloperContextType = { orgId: string, projectId: string, keyName: string, - secret: string + secret: string, ) => Promise; /** @@ -314,7 +322,10 @@ export type OpenSecretDeveloperContextType = { * @param orgId - Organization ID * @param projectId - Project ID */ - listProjectSecrets: (orgId: string, projectId: string) => Promise; + listProjectSecrets: ( + orgId: string, + projectId: string, + ) => Promise; /** * Deletes a project secret @@ -322,14 +333,21 @@ export type OpenSecretDeveloperContextType = { * @param projectId - Project ID * @param keyName - Secret key name */ - deleteProjectSecret: (orgId: string, projectId: string, keyName: string) => Promise; + deleteProjectSecret: ( + orgId: string, + projectId: string, + keyName: string, + ) => Promise; /** * Gets email configuration for a project * @param orgId - Organization ID * @param projectId - Project ID */ - getEmailSettings: (orgId: string, projectId: string) => Promise; + getEmailSettings: ( + orgId: string, + projectId: string, + ) => Promise; /** * Updates email configuration @@ -340,7 +358,7 @@ export type OpenSecretDeveloperContextType = { updateEmailSettings: ( orgId: string, projectId: string, - settings: EmailSettings + settings: EmailSettings, ) => Promise; /** @@ -348,7 +366,10 @@ export type OpenSecretDeveloperContextType = { * @param orgId - Organization ID * @param projectId - Project ID */ - getOAuthSettings: (orgId: string, projectId: string) => Promise; + getOAuthSettings: ( + orgId: string, + projectId: string, + ) => Promise; /** * Updates OAuth configuration @@ -359,7 +380,7 @@ export type OpenSecretDeveloperContextType = { updateOAuthSettings: ( orgId: string, projectId: string, - settings: OAuthSettings + settings: OAuthSettings, ) => Promise; /** @@ -368,7 +389,11 @@ export type OpenSecretDeveloperContextType = { * @param email - Developer's email address * @param role - Role to assign (defaults to "admin") */ - inviteDeveloper: (orgId: string, email: string, role?: string) => Promise; + inviteDeveloper: ( + orgId: string, + email: string, + role?: string, + ) => Promise; /** * Lists all members of an organization @@ -387,14 +412,20 @@ export type OpenSecretDeveloperContextType = { * @param orgId - Organization ID * @param inviteCode - Invitation UUID code */ - getOrganizationInvite: (orgId: string, inviteCode: string) => Promise; + getOrganizationInvite: ( + orgId: string, + inviteCode: string, + ) => Promise; /** * Deletes an invitation * @param orgId - Organization ID * @param inviteCode - Invitation UUID code */ - deleteOrganizationInvite: (orgId: string, inviteCode: string) => Promise<{ message: string }>; + deleteOrganizationInvite: ( + orgId: string, + inviteCode: string, + ) => Promise<{ message: string }>; /** * Updates a member's role @@ -402,7 +433,11 @@ export type OpenSecretDeveloperContextType = { * @param userId - User ID to update * @param role - New role to assign */ - updateMemberRole: (orgId: string, userId: string, role: string) => Promise; + updateMemberRole: ( + orgId: string, + userId: string, + role: string, + ) => Promise; /** * Removes a member from the organization @@ -423,63 +458,68 @@ export type OpenSecretDeveloperContextType = { apiUrl: string; }; -export const OpenSecretDeveloperContext = createContext({ - auth: { - loading: true, - developer: undefined - }, - signIn: async () => { - throw new Error("signIn called outside of OpenSecretDeveloper provider"); - }, - signUp: async () => { - throw new Error("signUp called outside of OpenSecretDeveloper provider"); - }, - signOut: async () => { - throw new Error("signOut called outside of OpenSecretDeveloper provider"); - }, - refetchDeveloper: async () => { - throw new Error("refetchDeveloper called outside of OpenSecretDeveloper provider"); - }, - verifyEmail: platformApi.verifyPlatformEmail, - requestNewVerificationCode: platformApi.requestNewPlatformVerificationCode, - requestNewVerificationEmail: platformApi.requestNewPlatformVerificationCode, - requestPasswordReset: platformApi.requestPlatformPasswordReset, - confirmPasswordReset: platformApi.confirmPlatformPasswordReset, - changePassword: platformApi.changePlatformPassword, - pcrConfig: {}, - getAttestation, - authenticate, - parseAttestationForView, - awsRootCertDer: AWS_ROOT_CERT_DER, - expectedRootCertHash: EXPECTED_ROOT_CERT_HASH, - getAttestationDocument: async () => { - throw new Error("getAttestationDocument called outside of OpenSecretDeveloper provider"); - }, - createOrganization: platformApi.createOrganization, - listOrganizations: platformApi.listOrganizations, - deleteOrganization: platformApi.deleteOrganization, - createProject: platformApi.createProject, - listProjects: platformApi.listProjects, - getProject: platformApi.getProject, - updateProject: platformApi.updateProject, - deleteProject: platformApi.deleteProject, - createProjectSecret: platformApi.createProjectSecret, - listProjectSecrets: platformApi.listProjectSecrets, - deleteProjectSecret: platformApi.deleteProjectSecret, - getEmailSettings: platformApi.getEmailSettings, - updateEmailSettings: platformApi.updateEmailSettings, - getOAuthSettings: platformApi.getOAuthSettings, - updateOAuthSettings: platformApi.updateOAuthSettings, - inviteDeveloper: platformApi.inviteDeveloper, - listOrganizationMembers: platformApi.listOrganizationMembers, - listOrganizationInvites: platformApi.listOrganizationInvites, - getOrganizationInvite: platformApi.getOrganizationInvite, - deleteOrganizationInvite: platformApi.deleteOrganizationInvite, - updateMemberRole: platformApi.updateMemberRole, - removeMember: platformApi.removeMember, - acceptInvite: platformApi.acceptInvite, - apiUrl: "" -}); +export const OpenSecretDeveloperContext = + createContext({ + auth: { + loading: true, + developer: undefined, + }, + signIn: async () => { + throw new Error('signIn called outside of OpenSecretDeveloper provider'); + }, + signUp: async () => { + throw new Error('signUp called outside of OpenSecretDeveloper provider'); + }, + signOut: async () => { + throw new Error('signOut called outside of OpenSecretDeveloper provider'); + }, + refetchDeveloper: async () => { + throw new Error( + 'refetchDeveloper called outside of OpenSecretDeveloper provider', + ); + }, + verifyEmail: platformApi.verifyPlatformEmail, + requestNewVerificationCode: platformApi.requestNewPlatformVerificationCode, + requestNewVerificationEmail: platformApi.requestNewPlatformVerificationCode, + requestPasswordReset: platformApi.requestPlatformPasswordReset, + confirmPasswordReset: platformApi.confirmPlatformPasswordReset, + changePassword: platformApi.changePlatformPassword, + pcrConfig: {}, + getAttestation, + authenticate, + parseAttestationForView, + awsRootCertDer: AWS_ROOT_CERT_DER, + expectedRootCertHash: EXPECTED_ROOT_CERT_HASH, + getAttestationDocument: async () => { + throw new Error( + 'getAttestationDocument called outside of OpenSecretDeveloper provider', + ); + }, + createOrganization: platformApi.createOrganization, + listOrganizations: platformApi.listOrganizations, + deleteOrganization: platformApi.deleteOrganization, + createProject: platformApi.createProject, + listProjects: platformApi.listProjects, + getProject: platformApi.getProject, + updateProject: platformApi.updateProject, + deleteProject: platformApi.deleteProject, + createProjectSecret: platformApi.createProjectSecret, + listProjectSecrets: platformApi.listProjectSecrets, + deleteProjectSecret: platformApi.deleteProjectSecret, + getEmailSettings: platformApi.getEmailSettings, + updateEmailSettings: platformApi.updateEmailSettings, + getOAuthSettings: platformApi.getOAuthSettings, + updateOAuthSettings: platformApi.updateOAuthSettings, + inviteDeveloper: platformApi.inviteDeveloper, + listOrganizationMembers: platformApi.listOrganizationMembers, + listOrganizationInvites: platformApi.listOrganizationInvites, + getOrganizationInvite: platformApi.getOrganizationInvite, + deleteOrganizationInvite: platformApi.deleteOrganizationInvite, + updateMemberRole: platformApi.updateMemberRole, + removeMember: platformApi.removeMember, + acceptInvite: platformApi.acceptInvite, + apiUrl: '', + }); /** * Provider component for OpenSecret developer operations. @@ -501,7 +541,7 @@ export const OpenSecretDeveloperContext = createContext({ loading: true, - developer: undefined + developer: undefined, }); useEffect(() => { - if (!apiUrl || apiUrl.trim() === "") { + if (!apiUrl || apiUrl.trim() === '') { throw new Error( - "OpenSecretDeveloper requires a non-empty apiUrl. Please provide a valid API endpoint URL." + 'OpenSecretDeveloper requires a non-empty apiUrl. Please provide a valid API endpoint URL.', ); } setPlatformApiUrl(apiUrl); @@ -523,12 +563,12 @@ export function OpenSecretDeveloper({ }, [apiUrl]); async function fetchDeveloper() { - const access_token = window.localStorage.getItem("access_token"); - const refresh_token = window.localStorage.getItem("refresh_token"); + const access_token = getStorage().persistent.getItem('access_token'); + const refresh_token = getStorage().persistent.getItem('refresh_token'); if (!access_token || !refresh_token) { setAuth({ loading: false, - developer: undefined + developer: undefined, }); return; } @@ -539,32 +579,36 @@ export function OpenSecretDeveloper({ loading: false, developer: { ...response.user, - organizations: response.organizations - } + organizations: response.organizations, + }, }); } catch (error) { - console.error("Failed to fetch developer:", error); + console.error('Failed to fetch developer:', error); setAuth({ loading: false, - developer: undefined + developer: undefined, }); } } const getAttestationDocument = async () => { - const nonce = window.crypto.randomUUID(); + const nonce = globalThis.crypto.randomUUID(); const response = await fetch(`${apiUrl}/attestation/${nonce}`); if (!response.ok) { - throw new Error("Failed to fetch attestation document"); + throw new Error('Failed to fetch attestation document'); } const data = await response.json(); const verifiedDocument = await authenticate( data.attestation_document, AWS_ROOT_CERT_DER, - nonce + nonce, + ); + return parseAttestationForView( + verifiedDocument, + verifiedDocument.cabundle, + pcrConfig, ); - return parseAttestationForView(verifiedDocument, verifiedDocument.cabundle, pcrConfig); }; useEffect(() => { @@ -573,31 +617,35 @@ export function OpenSecretDeveloper({ async function signIn(email: string, password: string) { try { - const { access_token, refresh_token } = await platformApi.platformLogin(email, password); - window.localStorage.setItem("access_token", access_token); - window.localStorage.setItem("refresh_token", refresh_token); + const { access_token, refresh_token } = await platformApi.platformLogin( + email, + password, + ); + getStorage().persistent.setItem('access_token', access_token); + getStorage().persistent.setItem('refresh_token', refresh_token); await fetchDeveloper(); - return { access_token, refresh_token, id: "", email }; + return { access_token, refresh_token, id: '', email }; } catch (error) { - console.error("Login error:", error); + console.error('Login error:', error); throw error; } } - async function signUp(email: string, password: string, invite_code: string, name?: string) { + async function signUp( + email: string, + password: string, + invite_code: string, + name?: string, + ) { try { - const { access_token, refresh_token } = await platformApi.platformRegister( - email, - password, - invite_code, - name - ); - window.localStorage.setItem("access_token", access_token); - window.localStorage.setItem("refresh_token", refresh_token); + const { access_token, refresh_token } = + await platformApi.platformRegister(email, password, invite_code, name); + getStorage().persistent.setItem('access_token', access_token); + getStorage().persistent.setItem('refresh_token', refresh_token); await fetchDeveloper(); - return { access_token, refresh_token, id: "", email, name }; + return { access_token, refresh_token, id: '', email, name }; } catch (error) { - console.error("Registration error:", error); + console.error('Registration error:', error); throw error; } } @@ -608,19 +656,19 @@ export function OpenSecretDeveloper({ signUp, refetchDeveloper: fetchDeveloper, signOut: async () => { - const refresh_token = window.localStorage.getItem("refresh_token"); + const refresh_token = getStorage().persistent.getItem('refresh_token'); if (refresh_token) { try { await platformApi.platformLogout(refresh_token); } catch (error) { - console.error("Error during logout:", error); + console.error('Error during logout:', error); } } - localStorage.removeItem("access_token"); - localStorage.removeItem("refresh_token"); + getStorage().persistent.removeItem('access_token'); + getStorage().persistent.removeItem('refresh_token'); setAuth({ loading: false, - developer: undefined + developer: undefined, }); }, verifyEmail: platformApi.verifyPlatformEmail, @@ -659,7 +707,7 @@ export function OpenSecretDeveloper({ updateMemberRole: platformApi.updateMemberRole, removeMember: platformApi.removeMember, acceptInvite: platformApi.acceptInvite, - apiUrl + apiUrl, }; return ( diff --git a/src/lib/encryptedApi.ts b/src/lib/encryptedApi.ts index ee5298c..a4e5d75 100644 --- a/src/lib/encryptedApi.ts +++ b/src/lib/encryptedApi.ts @@ -1,8 +1,9 @@ -import { encryptMessage, decryptMessage } from "./encryption"; -import { getAttestation } from "./getAttestation"; -import { refreshToken } from "./api"; -import { platformRefreshToken } from "./platformApi"; -import { apiConfig } from "./apiConfig"; +import { encryptMessage, decryptMessage } from './encryption'; +import { getAttestation } from './getAttestation'; +import { refreshToken } from './api'; +import { platformRefreshToken } from './platformApi'; +import { apiConfig } from './apiConfig'; +import { getStorage } from './storage'; interface EncryptedResponse { encrypted: string; @@ -18,28 +19,30 @@ export async function authenticatedApiCall( url: string, method: string, data: T, - errorMessage?: string + errorMessage?: string, ): Promise { - const tryAuthenticatedRequest = async (forceRefresh: boolean = false): Promise => { + const tryAuthenticatedRequest = async ( + forceRefresh: boolean = false, + ): Promise => { try { if (forceRefresh) { - console.log("Refreshing access token"); + console.log('Refreshing access token'); // Use the apiConfig to determine which refresh function to use const refreshFn = apiConfig.getRefreshFunction(url); console.log(`Using ${refreshFn}`); - if (refreshFn === "platformRefreshToken") { + if (refreshFn === 'platformRefreshToken') { await platformRefreshToken(); } else { await refreshToken(); } } - // Always get the latest token from localStorage - const accessToken = window.localStorage.getItem("access_token"); + // Always get the latest token from storage + const accessToken = getStorage().persistent.getItem('access_token'); if (!accessToken) { - throw new Error("No access token available"); + throw new Error('No access token available'); } const response = await internalEncryptedApiCall( @@ -47,7 +50,7 @@ export async function authenticatedApiCall( method, data, accessToken, - errorMessage + errorMessage, ); // Attempt to refresh token once if we get a 401 @@ -63,7 +66,7 @@ export async function authenticatedApiCall( // Throw an error if no data was received if (!response.data) { - throw new Error("No data received from the server"); + throw new Error('No data received from the server'); } return response.data; @@ -82,7 +85,7 @@ export async function openAiAuthenticatedApiCall( method: string, data: T, errorMessage?: string, - apiKey?: string + apiKey?: string, ): Promise { // If no API key provided, use regular authenticated call if (!apiKey) { @@ -90,7 +93,13 @@ export async function openAiAuthenticatedApiCall( } // For API key auth, call internal encrypted API directly (no refresh logic) - const response = await internalEncryptedApiCall(url, method, data, apiKey, errorMessage); + const response = await internalEncryptedApiCall( + url, + method, + data, + apiKey, + errorMessage, + ); if (response.error) { throw new Error(response.error); @@ -109,15 +118,19 @@ async function internalEncryptedApiCall( method: string, data: T, accessToken?: string, - errorMessage?: string + errorMessage?: string, ): Promise> { // Use apiConfig to determine the context and get the appropriate API URL const endpoint = apiConfig.resolveEndpoint(url); - const explicitApiUrl = endpoint.context === "platform" ? apiConfig.platformApiUrl : undefined; + const explicitApiUrl = + endpoint.context === 'platform' ? apiConfig.platformApiUrl : undefined; let { sessionKey, sessionId } = await getAttestation(false, explicitApiUrl); - const makeRequest = async (token: string | undefined, forceNewAttestation: boolean = false) => { + const makeRequest = async ( + token: string | undefined, + forceNewAttestation: boolean = false, + ) => { if (forceNewAttestation || !sessionKey || !sessionId) { const newAttestation = await getAttestation(true, explicitApiUrl); sessionKey = newAttestation.sessionKey; @@ -125,49 +138,60 @@ async function internalEncryptedApiCall( } if (!sessionKey || !sessionId) { - throw new Error("Failed to make encrypted API call, no attestation available."); + throw new Error( + 'Failed to make encrypted API call, no attestation available.', + ); } const jsonData = data ? JSON.stringify(data) : undefined; - const encryptedData = jsonData ? encryptMessage(sessionKey, jsonData) : undefined; + const encryptedData = jsonData + ? encryptMessage(sessionKey, jsonData) + : undefined; const headers: Record = { - "Content-Type": "application/json", - "x-session-id": sessionId + 'Content-Type': 'application/json', + 'x-session-id': sessionId, }; // Only add Authorization header if a token is provided if (token) { - headers["Authorization"] = `Bearer ${token}`; + headers['Authorization'] = `Bearer ${token}`; } const response = await fetch(url, { method, headers, - body: encryptedData ? JSON.stringify({ encrypted: encryptedData }) : undefined + body: encryptedData + ? JSON.stringify({ encrypted: encryptedData }) + : undefined, }); const result: ApiResponse = { - status: response.status + status: response.status, }; if (!response.ok) { try { const errorBody = await response.json(); result.error = - errorBody.message || errorMessage || `HTTP error! Status: ${response.status}`; + errorBody.message || + errorMessage || + `HTTP error! Status: ${response.status}`; } catch { result.error = errorMessage || `HTTP error! Status: ${response.status}`; } } else { try { const encryptedResponse: EncryptedResponse = await response.json(); - const decryptedResponse = decryptMessage(sessionKey, encryptedResponse.encrypted); + const decryptedResponse = decryptMessage( + sessionKey, + encryptedResponse.encrypted, + ); result.data = JSON.parse(decryptedResponse); } catch (error) { - console.error("Error decrypting or parsing response:", error); + console.error('Error decrypting or parsing response:', error); result.status = 500; - result.error = "Failed to decrypt or parse the response"; + result.error = 'Failed to decrypt or parse the response'; } } @@ -176,15 +200,20 @@ async function internalEncryptedApiCall( const tryEncryptedRequest = async ( token: string | undefined, - forceNewAttestation: boolean = false + forceNewAttestation: boolean = false, ): Promise> => { try { const response = await makeRequest(token, forceNewAttestation); // Retry with new attestation if we get a 400 or encryption error, but only once - if (response.status === 400 || response.error?.includes("Encryption error")) { + if ( + response.status === 400 || + response.error?.includes('Encryption error') + ) { if (!forceNewAttestation) { - console.log("Encryption error or Bad Request, attempting to renew attestation"); + console.log( + 'Encryption error or Bad Request, attempting to renew attestation', + ); return tryEncryptedRequest(token, true); } } @@ -193,7 +222,8 @@ async function internalEncryptedApiCall( } catch (error) { return { status: 500, - error: error instanceof Error ? error.message : "Unknown error occurred" + error: + error instanceof Error ? error.message : 'Unknown error occurred', }; } }; @@ -206,14 +236,14 @@ export async function encryptedApiCall( method: string, data: T, accessToken?: string, - errorMessage?: string + errorMessage?: string, ): Promise { const response = await internalEncryptedApiCall( url, method, data, accessToken, - errorMessage + errorMessage, ); // Throw an error if the response contains an error message @@ -223,7 +253,7 @@ export async function encryptedApiCall( // Throw an error if no data was received if (!response.data) { - throw new Error("No data received from the server"); + throw new Error('No data received from the server'); } return response.data; diff --git a/src/lib/getAttestation.ts b/src/lib/getAttestation.ts index a8d1921..7f72e75 100644 --- a/src/lib/getAttestation.ts +++ b/src/lib/getAttestation.ts @@ -1,15 +1,19 @@ -import { verifyAttestation } from "./attestation"; -import { keyExchange } from "./api"; -import nacl from "tweetnacl"; -import { ChaCha20Poly1305 } from "@stablelib/chacha20poly1305"; -import { encode, decode } from "@stablelib/base64"; +import { verifyAttestation } from './attestation'; +import { keyExchange } from './api'; +import nacl from 'tweetnacl'; +import { ChaCha20Poly1305 } from '@stablelib/chacha20poly1305'; +import { encode, decode } from '@stablelib/base64'; +import { getStorage } from './storage'; export interface Attestation { sessionKey: Uint8Array | null; sessionId: string | null; } -function generateNaclKeyPair(): { publicKey: Uint8Array; secretKey: Uint8Array } { +function generateNaclKeyPair(): { + publicKey: Uint8Array; + secretKey: Uint8Array; +} { const testNaclPublicKey = import.meta.env.VITE_TEST_NACL_PUBLIC_KEY; const testNaclSecretKey = import.meta.env.VITE_TEST_NACL_SECRET_KEY; @@ -17,7 +21,7 @@ function generateNaclKeyPair(): { publicKey: Uint8Array; secretKey: Uint8Array } if (testNaclPublicKey && testNaclSecretKey) { return { publicKey: decode(testNaclPublicKey), - secretKey: decode(testNaclSecretKey) + secretKey: decode(testNaclSecretKey), }; } @@ -27,43 +31,46 @@ function generateNaclKeyPair(): { publicKey: Uint8Array; secretKey: Uint8Array } export async function getAttestation( forceRefresh?: boolean, - explicitApiUrl?: string + explicitApiUrl?: string, ): Promise { - // Check if we already have a sessionKey and sessionId in sessionstorage - const sessionKey = sessionStorage.getItem("sessionKey"); - const sessionId = sessionStorage.getItem("sessionId"); + // Check if we already have a sessionKey and sessionId in session storage + const sessionKey = getStorage().session.getItem('sessionKey'); + const sessionId = getStorage().session.getItem('sessionId'); - console.groupCollapsed("Attestation"); + console.groupCollapsed('Attestation'); try { // Attestation already set up if (sessionKey && sessionId && !forceRefresh) { const key = decode(sessionKey); - console.log("Using existing attestation from session storage."); + console.log('Using existing attestation from session storage.'); return { sessionKey: key, sessionId }; } // Need to get a new attestation // (Will use test nonce if provided) - const attestationNonce = window.crypto.randomUUID(); + const attestationNonce = globalThis.crypto.randomUUID(); - console.log("Generated attestation nonce:", attestationNonce); + console.log('Generated attestation nonce:', attestationNonce); const document = await verifyAttestation(attestationNonce, explicitApiUrl); if (document && document.public_key) { - console.log("Attestation document verification succeeded"); + console.log('Attestation document verification succeeded'); const clientKeyPair = generateNaclKeyPair(); - console.log("Generated client key pair"); + console.log('Generated client key pair'); const serverPublicKey = new Uint8Array(document.public_key); const { encrypted_session_key, session_id } = await keyExchange( encode(clientKeyPair.publicKey), attestationNonce, - explicitApiUrl + explicitApiUrl, ); - console.log("Key exchange completed."); + console.log('Key exchange completed.'); - const sharedSecret = nacl.scalarMult(clientKeyPair.secretKey, serverPublicKey); + const sharedSecret = nacl.scalarMult( + clientKeyPair.secretKey, + serverPublicKey, + ); const encryptedData = decode(encrypted_session_key); @@ -75,18 +82,18 @@ export async function getAttestation( const decryptedSessionKey = chacha.open(decryptionNonce, ciphertext); if (decryptedSessionKey) { - console.log("Session key decrypted successfully"); - window.sessionStorage.setItem("sessionKey", encode(decryptedSessionKey)); - window.sessionStorage.setItem("sessionId", session_id); + console.log('Session key decrypted successfully'); + getStorage().session.setItem('sessionKey', encode(decryptedSessionKey)); + getStorage().session.setItem('sessionId', session_id); return { sessionKey: decryptedSessionKey, sessionId: session_id }; } else { - throw new Error("Failed to decrypt session key"); + throw new Error('Failed to decrypt session key'); } } else { - throw new Error("Invalid attestation document"); + throw new Error('Invalid attestation document'); } } catch (error) { - console.error("Error verifying attestation:", error); + console.error('Error verifying attestation:', error); throw error; } finally { console.groupEnd(); diff --git a/src/lib/index.ts b/src/lib/index.ts index f74da29..63f22ec 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -37,24 +37,28 @@ export type { PublicKeyResponse, SignMessageResponse, EncryptDataResponse, - AppleUser -} from "./api"; + AppleUser, +} from './api'; // Export API key management functions -export { createApiKey, listApiKeys, deleteApiKey } from "./api"; +export { createApiKey, listApiKeys, deleteApiKey } from './api'; // Export AI customization options -export { createCustomFetch, type CustomFetchOptions } from "./ai"; +export { createCustomFetch, type CustomFetchOptions } from './ai'; // Re-export Model type from OpenAI for convenience -export type { Model } from "openai/resources/models.js"; +export type { Model } from 'openai/resources/models.js'; // Export configuration functions -export { configure, getConfig, isConfigured, resetConfig } from "./config"; -export type { OpenSecretConfig } from "./config"; +export { configure, getConfig, isConfigured, resetConfig } from './config'; +export type { OpenSecretConfig } from './config'; + +// Export storage abstraction +export { getStorage } from './storage'; +export type { StorageProvider } from './storage'; // Export API configuration -export { apiConfig, type ApiContext, type ApiEndpoint } from "./apiConfig"; +export { apiConfig, type ApiContext, type ApiEndpoint } from './apiConfig'; // Export all API functions directly export { @@ -65,7 +69,6 @@ export { fetchGuestSignUp as signUpGuest, fetchLogout as signOut, convertGuestToEmailAccount as convertGuestToUserAccount, - // User management fetchUser, refreshToken as refreshAccessToken, @@ -76,7 +79,6 @@ export { confirmPasswordReset, requestAccountDeletion, confirmAccountDeletion, - // OAuth initiateGitHubAuth, handleGitHubCallback, @@ -85,13 +87,11 @@ export { initiateAppleAuth, handleAppleCallback, handleAppleNativeSignIn, - // Key-value storage fetchGet as get, fetchPut as put, fetchList as list, fetchDelete as del, - // Cryptographic operations fetchPrivateKey as getPrivateKey, fetchPrivateKeyBytes as getPrivateKeyBytes, @@ -99,56 +99,52 @@ export { signMessage, encryptData, decryptData, - // Third-party tokens generateThirdPartyToken, - // AI fetchModels, - // Document processing uploadDocument, checkDocumentStatus, uploadDocumentWithPolling, - // Utility - getApiUrl -} from "./api"; + getApiUrl, +} from './api'; // Export AI custom fetch -export { createCustomFetch as createAiCustomFetch } from "./ai"; +export { createCustomFetch as createAiCustomFetch } from './ai'; // Export attestation functions -export { getAttestation } from "./getAttestation"; -export { authenticate } from "./attestation"; -export { +export { getAttestation } from './getAttestation'; +export { authenticate } from './attestation'; +export { parseAttestationForView, AWS_ROOT_CERT_DER as awsRootCertDer, - EXPECTED_ROOT_CERT_HASH as expectedRootCertHash -} from "./attestationForView"; + EXPECTED_ROOT_CERT_HASH as expectedRootCertHash, +} from './attestationForView'; // Export the provider and context -export { OpenSecretProvider, OpenSecretContext } from "./main"; -export { OpenSecretDeveloper, OpenSecretDeveloperContext } from "./developer"; +export { OpenSecretProvider, OpenSecretContext } from './main'; +export { OpenSecretDeveloper, OpenSecretDeveloperContext } from './developer'; // Export the hooks -export { useOpenSecret } from "./context"; -export { useOpenSecretDeveloper } from "./developerContext"; +export { useOpenSecret } from './context'; +export { useOpenSecretDeveloper } from './developerContext'; // Export types needed by consumers -export type { OpenSecretAuthState, OpenSecretContextType } from "./main"; +export type { OpenSecretAuthState, OpenSecretContextType } from './main'; export type { OpenSecretDeveloperAuthState, OpenSecretDeveloperContextType, DeveloperRole, OrganizationDetails, ProjectDetails, - ProjectSettings -} from "./developer"; -export type { AttestationDocument } from "./attestation"; -export type { ParsedAttestationView } from "./attestationForView"; -export type { PcrConfig, Pcr0ValidationResult } from "./pcr"; + ProjectSettings, +} from './developer'; +export type { AttestationDocument } from './attestation'; +export type { ParsedAttestationView } from './attestationForView'; +export type { PcrConfig, Pcr0ValidationResult } from './pcr'; // Export crypto utilities // TODO: these can actually just be used internally by the password reset function -export { generateSecureSecret, hashSecret } from "./crypto"; +export { generateSecureSecret, hashSecret } from './crypto'; diff --git a/src/lib/main.tsx b/src/lib/main.tsx index a1418cd..8bb5f6a 100644 --- a/src/lib/main.tsx +++ b/src/lib/main.tsx @@ -1,19 +1,24 @@ -import React, { createContext, useState, useEffect } from "react"; -import * as api from "./api"; -import { createCustomFetch } from "./ai"; -import { getAttestation } from "./getAttestation"; -import type { Model } from "openai/resources/models.js"; -import { authenticate } from "./attestation"; +import React, { createContext, useState, useEffect } from 'react'; +import * as api from './api'; +import { createCustomFetch } from './ai'; +import { getAttestation } from './getAttestation'; +import { getStorage } from './storage'; +import type { Model } from 'openai/resources/models.js'; +import { authenticate } from './attestation'; import { parseAttestationForView, AWS_ROOT_CERT_DER, EXPECTED_ROOT_CERT_HASH, - ParsedAttestationView -} from "./attestationForView"; -import type { AttestationDocument } from "./attestation"; -import type { LoginResponse, ThirdPartyTokenResponse, DocumentResponse } from "./api"; -import { PcrConfig } from "./pcr"; -import { configure } from "./config"; + ParsedAttestationView, +} from './attestationForView'; +import type { AttestationDocument } from './attestation'; +import type { + LoginResponse, + ThirdPartyTokenResponse, + DocumentResponse, +} from './api'; +import { PcrConfig } from './pcr'; +import { configure } from './config'; export type OpenSecretAuthState = { loading: boolean; @@ -71,7 +76,12 @@ export type OpenSecretContextType = { * - Updates the auth state with new user information * - Throws an error if account creation fails */ - signUp: (email: string, password: string, inviteCode: string, name?: string) => Promise; + signUp: ( + email: string, + password: string, + inviteCode: string, + name?: string, + ) => Promise; /** * Authenticates a guest user with user id and password @@ -123,7 +133,7 @@ export type OpenSecretContextType = { convertGuestToUserAccount: ( email: string, password: string, - name?: string | null + name?: string | null, ) => Promise; /** @@ -214,7 +224,7 @@ export type OpenSecretContextType = { email: string, alphanumericCode: string, plaintextSecret: string, - newPassword: string + newPassword: string, ) => Promise; /** * Initiates the account deletion process for logged-in users @@ -243,14 +253,32 @@ export type OpenSecretContextType = { * 3. Permanently deletes the user account and all associated data * 4. After successful deletion, the client should clear all local storage and tokens */ - confirmAccountDeletion: (confirmationCode: string, plaintextSecret: string) => Promise; + confirmAccountDeletion: ( + confirmationCode: string, + plaintextSecret: string, + ) => Promise; initiateGitHubAuth: (inviteCode: string) => Promise; - handleGitHubCallback: (code: string, state: string, inviteCode: string) => Promise; + handleGitHubCallback: ( + code: string, + state: string, + inviteCode: string, + ) => Promise; initiateGoogleAuth: (inviteCode: string) => Promise; - handleGoogleCallback: (code: string, state: string, inviteCode: string) => Promise; + handleGoogleCallback: ( + code: string, + state: string, + inviteCode: string, + ) => Promise; initiateAppleAuth: (inviteCode: string) => Promise; - handleAppleCallback: (code: string, state: string, inviteCode: string) => Promise; - handleAppleNativeSignIn: (appleUser: api.AppleUser, inviteCode?: string) => Promise; + handleAppleCallback: ( + code: string, + state: string, + inviteCode: string, + ) => Promise; + handleAppleNativeSignIn: ( + appleUser: api.AppleUser, + inviteCode?: string, + ) => Promise; /** * Retrieves the user's private key mnemonic phrase @@ -365,7 +393,10 @@ export type OpenSecretContextType = { * }); * ``` */ - aiCustomFetch: (input: string | URL | Request, init?: RequestInit) => Promise; + aiCustomFetch: ( + input: string | URL | Request, + init?: RequestInit, + ) => Promise; /** * Returns the current OpenSecret enclave API URL being used @@ -394,7 +425,7 @@ export type OpenSecretContextType = { parseAttestationForView: ( document: AttestationDocument, cabundle: Uint8Array[], - pcrConfig?: PcrConfig + pcrConfig?: PcrConfig, ) => Promise; /** @@ -435,7 +466,9 @@ export type OpenSecretContextType = { * - Requires an active authentication session * - Token can be used to authenticate with the specified service */ - generateThirdPartyToken: (audience?: string) => Promise; + generateThirdPartyToken: ( + audience?: string, + ) => Promise; /** * Encrypts arbitrary string data using the user's private key @@ -547,7 +580,9 @@ export type OpenSecretContextType = { * console.log(result.task_id); // Task ID to check status * ``` */ - uploadDocument: (file: File | Blob) => Promise; + uploadDocument: ( + file: File | Blob, + ) => Promise; /** * Checks the status of a document processing task @@ -612,7 +647,7 @@ export type OpenSecretContextType = { pollInterval?: number; maxAttempts?: number; onProgress?: (status: string, progress?: number) => void; - } + }, ) => Promise; /** @@ -716,7 +751,9 @@ export type OpenSecretContextType = { * }); * ``` */ - fetchResponsesList: (params?: api.ResponsesListParams) => Promise; + fetchResponsesList: ( + params?: api.ResponsesListParams, + ) => Promise; /** * Retrieves a single response by ID @@ -854,19 +891,19 @@ export type OpenSecretContextType = { export const OpenSecretContext = createContext({ auth: { loading: true, - user: undefined + user: undefined, }, - clientId: "", + clientId: '', apiKey: undefined, setApiKey: () => {}, signIn: async () => {}, signUp: async () => {}, signInGuest: async () => {}, signUpGuest: async (): Promise => ({ - id: "", + id: '', email: undefined, - access_token: "", - refresh_token: "" + access_token: '', + refresh_token: '', }), convertGuestToUserAccount: async () => {}, signOut: async () => {}, @@ -885,11 +922,11 @@ export const OpenSecretContext = createContext({ confirmPasswordReset: async () => {}, requestAccountDeletion: async () => {}, confirmAccountDeletion: async () => {}, - initiateGitHubAuth: async () => ({ auth_url: "", csrf_token: "" }), + initiateGitHubAuth: async () => ({ auth_url: '', csrf_token: '' }), handleGitHubCallback: async () => {}, - initiateGoogleAuth: async () => ({ auth_url: "", csrf_token: "" }), + initiateGoogleAuth: async () => ({ auth_url: '', csrf_token: '' }), handleGoogleCallback: async () => {}, - initiateAppleAuth: async () => ({ auth_url: "", state: "" }), + initiateAppleAuth: async () => ({ auth_url: '', state: '' }), handleAppleCallback: async () => {}, handleAppleNativeSignIn: async () => {}, getPrivateKey: api.fetchPrivateKey, @@ -897,7 +934,7 @@ export const OpenSecretContext = createContext({ getPublicKey: api.fetchPublicKey, signMessage: api.signMessage, aiCustomFetch: async () => new Response(), - apiUrl: "", + apiUrl: '', pcrConfig: {}, getAttestation, authenticate, @@ -905,9 +942,11 @@ export const OpenSecretContext = createContext({ awsRootCertDer: AWS_ROOT_CERT_DER, expectedRootCertHash: EXPECTED_ROOT_CERT_HASH, getAttestationDocument: async () => { - throw new Error("getAttestationDocument called outside of OpenSecretProvider"); + throw new Error( + 'getAttestationDocument called outside of OpenSecretProvider', + ); }, - generateThirdPartyToken: async () => ({ token: "" }), + generateThirdPartyToken: async () => ({ token: '' }), encryptData: api.encryptData, decryptData: api.decryptData, fetchModels: api.fetchModels, @@ -931,27 +970,27 @@ export const OpenSecretContext = createContext({ getInstruction: api.getInstruction, updateInstruction: api.updateInstruction, deleteInstruction: api.deleteInstruction, - setDefaultInstruction: api.setDefaultInstruction + setDefaultInstruction: api.setDefaultInstruction, }); /** * Provider component for OpenSecret authentication and key-value storage. - * + * * @deprecated The OpenSecretProvider is deprecated. Instead, use the `configure` function * and import API functions directly. This provider will be removed in a future version. - * + * * Migration guide: * ```tsx * // Old approach (deprecated) * * * - * + * * // New approach * import { configure, signIn, get, put } from '@opensecret/react'; - * + * * configure({ apiUrl: '...', clientId: '...' }); - * + * * // Use functions directly * await signIn(email, password); * const value = await get('key'); @@ -984,7 +1023,7 @@ export function OpenSecretProvider({ children, apiUrl, clientId, - pcrConfig = {} + pcrConfig = {}, }: { children: React.ReactNode; apiUrl: string; @@ -993,10 +1032,11 @@ export function OpenSecretProvider({ }) { const [auth, setAuth] = useState({ loading: true, - user: undefined + user: undefined, }); const [apiKey, setApiKeyState] = useState(); - const [aiCustomFetch, setAiCustomFetch] = useState(); + const [aiCustomFetch, setAiCustomFetch] = + useState(); // Validates UUID-with-dashes (v1–v5) and trims input; set undefined to clear const setApiKey = (key: string | undefined) => { @@ -1008,7 +1048,9 @@ export function OpenSecretProvider({ const uuidWithDashes = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; if (!uuidWithDashes.test(trimmed)) { - console.warn("setApiKey: provided key does not look like a UUID; clearing apiKey"); + console.warn( + 'setApiKey: provided key does not look like a UUID; clearing apiKey', + ); setApiKeyState(undefined); return; } @@ -1016,17 +1058,17 @@ export function OpenSecretProvider({ }; useEffect(() => { - if (!apiUrl || apiUrl.trim() === "") { + if (!apiUrl || apiUrl.trim() === '') { throw new Error( - "OpenSecretProvider requires a non-empty apiUrl. Please provide a valid API endpoint URL." + 'OpenSecretProvider requires a non-empty apiUrl. Please provide a valid API endpoint URL.', ); } - if (!clientId || clientId.trim() === "") { + if (!clientId || clientId.trim() === '') { throw new Error( - "OpenSecretProvider requires a non-empty clientId. Please provide a valid project UUID." + 'OpenSecretProvider requires a non-empty clientId. Please provide a valid project UUID.', ); } - + // Configure the SDK with the provided values configure({ apiUrl, clientId }); }, [apiUrl, clientId]); @@ -1035,19 +1077,21 @@ export function OpenSecretProvider({ useEffect(() => { if (apiUrl) { // Pass API key if available, otherwise falls back to JWT - setAiCustomFetch(() => createCustomFetch(apiKey ? { apiKey } : undefined)); + setAiCustomFetch(() => + createCustomFetch(apiKey ? { apiKey } : undefined), + ); } else { setAiCustomFetch(undefined); } }, [apiUrl, apiKey]); async function fetchUser() { - const access_token = window.localStorage.getItem("access_token"); - const refresh_token = window.localStorage.getItem("refresh_token"); + const access_token = getStorage().persistent.getItem('access_token'); + const refresh_token = getStorage().persistent.getItem('refresh_token'); if (!access_token || !refresh_token) { setAuth({ loading: false, - user: undefined + user: undefined, }); return; } @@ -1056,13 +1100,13 @@ export function OpenSecretProvider({ const user = await api.fetchUser(); setAuth({ loading: false, - user + user, }); } catch (error) { - console.error("Failed to fetch user:", error); + console.error('Failed to fetch user:', error); setAuth({ loading: false, - user: undefined + user: undefined, }); } } @@ -1072,7 +1116,7 @@ export function OpenSecretProvider({ }, []); async function signIn(email: string, password: string) { - console.log("Signing in"); + console.log('Signing in'); try { await api.fetchLogin(email, password); setApiKey(undefined); @@ -1083,14 +1127,14 @@ export function OpenSecretProvider({ } } - async function signUp(email: string, password: string, inviteCode: string, name?: string) { + async function signUp( + email: string, + password: string, + inviteCode: string, + name?: string, + ) { try { - await api.fetchSignUp( - email, - password, - inviteCode, - name || null - ); + await api.fetchSignUp(email, password, inviteCode, name || null); setApiKey(undefined); await fetchUser(); } catch (error) { @@ -1100,7 +1144,7 @@ export function OpenSecretProvider({ } async function signInGuest(id: string, password: string) { - console.log("Signing in Guest"); + console.log('Signing in Guest'); try { await api.fetchGuestLogin(id, password); setApiKey(undefined); @@ -1113,10 +1157,7 @@ export function OpenSecretProvider({ async function signUpGuest(password: string, inviteCode: string) { try { - const response = await api.fetchGuestSignUp( - password, - inviteCode - ); + const response = await api.fetchGuestSignUp(password, inviteCode); setApiKey(undefined); await fetchUser(); return response; @@ -1126,7 +1167,11 @@ export function OpenSecretProvider({ } } - async function convertGuestToUserAccount(email: string, password: string, name?: string | null) { + async function convertGuestToUserAccount( + email: string, + password: string, + name?: string | null, + ) { try { await api.convertGuestToEmailAccount(email, password, name); await fetchUser(); @@ -1141,7 +1186,7 @@ export function OpenSecretProvider({ setApiKey(undefined); setAuth({ loading: false, - user: undefined + user: undefined, }); } @@ -1149,22 +1194,22 @@ export function OpenSecretProvider({ try { return await api.initiateGitHubAuth(inviteCode); } catch (error) { - console.error("Failed to initiate GitHub auth:", error); + console.error('Failed to initiate GitHub auth:', error); throw error; } }; - const handleGitHubCallback = async (code: string, state: string, inviteCode: string) => { + const handleGitHubCallback = async ( + code: string, + state: string, + inviteCode: string, + ) => { try { - await api.handleGitHubCallback( - code, - state, - inviteCode - ); + await api.handleGitHubCallback(code, state, inviteCode); setApiKey(undefined); await fetchUser(); } catch (error) { - console.error("GitHub callback error:", error); + console.error('GitHub callback error:', error); throw error; } }; @@ -1173,22 +1218,22 @@ export function OpenSecretProvider({ try { return await api.initiateGoogleAuth(inviteCode); } catch (error) { - console.error("Failed to initiate Google auth:", error); + console.error('Failed to initiate Google auth:', error); throw error; } }; - const handleGoogleCallback = async (code: string, state: string, inviteCode: string) => { + const handleGoogleCallback = async ( + code: string, + state: string, + inviteCode: string, + ) => { try { - await api.handleGoogleCallback( - code, - state, - inviteCode - ); + await api.handleGoogleCallback(code, state, inviteCode); setApiKey(undefined); await fetchUser(); } catch (error) { - console.error("Google callback error:", error); + console.error('Google callback error:', error); throw error; } }; @@ -1197,54 +1242,58 @@ export function OpenSecretProvider({ try { return await api.initiateAppleAuth(inviteCode); } catch (error) { - console.error("Failed to initiate Apple auth:", error); + console.error('Failed to initiate Apple auth:', error); throw error; } }; - const handleAppleCallback = async (code: string, state: string, inviteCode: string) => { + const handleAppleCallback = async ( + code: string, + state: string, + inviteCode: string, + ) => { try { - await api.handleAppleCallback( - code, - state, - inviteCode - ); + await api.handleAppleCallback(code, state, inviteCode); setApiKey(undefined); await fetchUser(); } catch (error) { - console.error("Apple callback error:", error); + console.error('Apple callback error:', error); throw error; } }; - const handleAppleNativeSignIn = async (appleUser: api.AppleUser, inviteCode?: string) => { + const handleAppleNativeSignIn = async ( + appleUser: api.AppleUser, + inviteCode?: string, + ) => { try { - await api.handleAppleNativeSignIn( - appleUser, - inviteCode - ); + await api.handleAppleNativeSignIn(appleUser, inviteCode); setApiKey(undefined); await fetchUser(); } catch (error) { - console.error("Apple native sign-in error:", error); + console.error('Apple native sign-in error:', error); throw error; } }; const getAttestationDocument = async () => { - const nonce = window.crypto.randomUUID(); + const nonce = globalThis.crypto.randomUUID(); const response = await fetch(`${apiUrl}/attestation/${nonce}`); if (!response.ok) { - throw new Error("Failed to fetch attestation document"); + throw new Error('Failed to fetch attestation document'); } const data = await response.json(); const verifiedDocument = await authenticate( data.attestation_document, AWS_ROOT_CERT_DER, - nonce + nonce, + ); + return parseAttestationForView( + verifiedDocument, + verifiedDocument.cabundle, + pcrConfig, ); - return parseAttestationForView(verifiedDocument, verifiedDocument.cabundle, pcrConfig); }; const value: OpenSecretContextType = { @@ -1317,8 +1366,12 @@ export function OpenSecretProvider({ getInstruction: api.getInstruction, updateInstruction: api.updateInstruction, deleteInstruction: api.deleteInstruction, - setDefaultInstruction: api.setDefaultInstruction + setDefaultInstruction: api.setDefaultInstruction, }; - return {children}; + return ( + + {children} + + ); } diff --git a/src/lib/platformApi.ts b/src/lib/platformApi.ts index 2814f22..3200918 100644 --- a/src/lib/platformApi.ts +++ b/src/lib/platformApi.ts @@ -1,4 +1,5 @@ -import { encryptedApiCall, authenticatedApiCall } from "./encryptedApi"; +import { encryptedApiCall, authenticatedApiCall } from './encryptedApi'; +import { getStorage } from './storage'; // Platform Auth Types export type PlatformLoginResponse = { @@ -107,7 +108,7 @@ export type OrganizationMember = { name?: string; }; -let platformApiUrl = ""; +let platformApiUrl = ''; export function setPlatformApiUrl(url: string) { platformApiUrl = url; @@ -120,14 +121,17 @@ export function getPlatformApiUrl(): string { // Platform Authentication export async function platformLogin( email: string, - password: string + password: string, ): Promise { - return encryptedApiCall<{ email: string; password: string }, PlatformLoginResponse>( + return encryptedApiCall< + { email: string; password: string }, + PlatformLoginResponse + >( `${platformApiUrl}/platform/login`, - "POST", + 'POST', { email, password }, undefined, - "Failed to login" + 'Failed to login', ); } @@ -143,27 +147,27 @@ export async function platformRegister( email: string, password: string, invite_code: string, - name?: string + name?: string, ): Promise { return encryptedApiCall< { email: string; password: string; invite_code: string; name?: string }, PlatformLoginResponse >( `${platformApiUrl}/platform/register`, - "POST", + 'POST', { email, password, invite_code, name }, undefined, - "Failed to register" + 'Failed to register', ); } export async function platformLogout(refresh_token: string): Promise { return encryptedApiCall<{ refresh_token: string }, void>( `${platformApiUrl}/platform/logout`, - "POST", + 'POST', { refresh_token }, undefined, - "Failed to logout" + 'Failed to logout', ); } @@ -182,25 +186,28 @@ export async function platformLogout(refresh_token: string): Promise { * It returns new access and refresh tokens if validation succeeds. */ export async function platformRefreshToken(): Promise { - const refresh_token = window.localStorage.getItem("refresh_token"); - if (!refresh_token) throw new Error("No refresh token available"); + const refresh_token = getStorage().persistent.getItem('refresh_token'); + if (!refresh_token) throw new Error('No refresh token available'); const refreshData = { refresh_token }; try { - const response = await encryptedApiCall( + const response = await encryptedApiCall< + typeof refreshData, + PlatformRefreshResponse + >( `${platformApiUrl}/platform/refresh`, - "POST", + 'POST', refreshData, undefined, - "Failed to refresh platform token" + 'Failed to refresh platform token', ); - window.localStorage.setItem("access_token", response.access_token); - window.localStorage.setItem("refresh_token", response.refresh_token); + getStorage().persistent.setItem('access_token', response.access_token); + getStorage().persistent.setItem('refresh_token', response.refresh_token); return response; } catch (error) { - console.error("Error refreshing platform token:", error); + console.error('Error refreshing platform token:', error); throw error; } } @@ -209,24 +216,24 @@ export async function platformRefreshToken(): Promise { export async function createOrganization(name: string): Promise { return authenticatedApiCall<{ name: string }, Organization>( `${platformApiUrl}/platform/orgs`, - "POST", - { name } + 'POST', + { name }, ); } export async function listOrganizations(): Promise { return authenticatedApiCall( `${platformApiUrl}/platform/orgs`, - "GET", - undefined + 'GET', + undefined, ); } export async function deleteOrganization(orgId: string): Promise { return authenticatedApiCall( `${platformApiUrl}/platform/orgs/${orgId}`, - "DELETE", - undefined + 'DELETE', + undefined, ); } @@ -234,48 +241,54 @@ export async function deleteOrganization(orgId: string): Promise { export async function createProject( orgId: string, name: string, - description?: string + description?: string, ): Promise { return authenticatedApiCall<{ name: string; description?: string }, Project>( `${platformApiUrl}/platform/orgs/${orgId}/projects`, - "POST", - { name, description } + 'POST', + { name, description }, ); } export async function listProjects(orgId: string): Promise { return authenticatedApiCall( `${platformApiUrl}/platform/orgs/${orgId}/projects`, - "GET", - undefined + 'GET', + undefined, ); } -export async function getProject(orgId: string, projectId: string): Promise { +export async function getProject( + orgId: string, + projectId: string, +): Promise { return authenticatedApiCall( `${platformApiUrl}/platform/orgs/${orgId}/projects/${projectId}`, - "GET", - undefined + 'GET', + undefined, ); } export async function updateProject( orgId: string, projectId: string, - updates: { name?: string; description?: string; status?: string } + updates: { name?: string; description?: string; status?: string }, ): Promise { return authenticatedApiCall( `${platformApiUrl}/platform/orgs/${orgId}/projects/${projectId}`, - "PATCH", - updates + 'PATCH', + updates, ); } -export async function deleteProject(orgId: string, projectId: string): Promise { +export async function deleteProject( + orgId: string, + projectId: string, +): Promise { return authenticatedApiCall( `${platformApiUrl}/platform/orgs/${orgId}/projects/${projectId}`, - "DELETE", - undefined + 'DELETE', + undefined, ); } @@ -299,84 +312,93 @@ export async function createProjectSecret( orgId: string, projectId: string, keyName: string, - secret: string + secret: string, ): Promise { // Validate that the secret is base64 encoded if (!isValidBase64(secret)) { throw new Error( - "Secret must be base64 encoded. Use @stablelib/base64's encode function to encode your data." + "Secret must be base64 encoded. Use @stablelib/base64's encode function to encode your data.", ); } - return authenticatedApiCall<{ key_name: string; secret: string }, ProjectSecret>( + return authenticatedApiCall< + { key_name: string; secret: string }, + ProjectSecret + >( `${platformApiUrl}/platform/orgs/${orgId}/projects/${projectId}/secrets`, - "POST", - { key_name: keyName, secret } + 'POST', + { key_name: keyName, secret }, ); } export async function listProjectSecrets( orgId: string, - projectId: string + projectId: string, ): Promise { return authenticatedApiCall( `${platformApiUrl}/platform/orgs/${orgId}/projects/${projectId}/secrets`, - "GET", - undefined + 'GET', + undefined, ); } export async function deleteProjectSecret( orgId: string, projectId: string, - keyName: string + keyName: string, ): Promise { return authenticatedApiCall( `${platformApiUrl}/platform/orgs/${orgId}/projects/${projectId}/secrets/${keyName}`, - "DELETE", - undefined + 'DELETE', + undefined, ); } // Email Settings -export async function getEmailSettings(orgId: string, projectId: string): Promise { +export async function getEmailSettings( + orgId: string, + projectId: string, +): Promise { return authenticatedApiCall( `${platformApiUrl}/platform/orgs/${orgId}/projects/${projectId}/settings/email`, - "GET", - undefined + 'GET', + undefined, ); } export async function updateEmailSettings( orgId: string, projectId: string, - settings: EmailSettings + settings: EmailSettings, ): Promise { return authenticatedApiCall( `${platformApiUrl}/platform/orgs/${orgId}/projects/${projectId}/settings/email`, - "PUT", - settings + 'PUT', + settings, ); } // OAuth Settings -export async function getOAuthSettings(orgId: string, projectId: string): Promise { +export async function getOAuthSettings( + orgId: string, + projectId: string, +): Promise { return authenticatedApiCall( `${platformApiUrl}/platform/orgs/${orgId}/projects/${projectId}/settings/oauth`, - "GET", - undefined + 'GET', + undefined, ); } export async function updateOAuthSettings( orgId: string, projectId: string, - settings: OAuthSettings + settings: OAuthSettings, ): Promise { return authenticatedApiCall( `${platformApiUrl}/platform/orgs/${orgId}/projects/${projectId}/settings/oauth`, - "PUT", - settings + 'PUT', + settings, ); } @@ -384,89 +406,102 @@ export async function updateOAuthSettings( export async function inviteDeveloper( orgId: string, email: string, - role?: string + role?: string, ): Promise { // Add validation for empty emails - if (!email || email.trim() === "") { - throw new Error("Email is required"); + if (!email || email.trim() === '') { + throw new Error('Email is required'); } - return authenticatedApiCall<{ email: string; role?: string }, OrganizationInvite>( - `${platformApiUrl}/platform/orgs/${orgId}/invites`, - "POST", - { email, role } - ); + return authenticatedApiCall< + { email: string; role?: string }, + OrganizationInvite + >(`${platformApiUrl}/platform/orgs/${orgId}/invites`, 'POST', { + email, + role, + }); } -export async function listOrganizationInvites(orgId: string): Promise { +export async function listOrganizationInvites( + orgId: string, +): Promise { return authenticatedApiCall( `${platformApiUrl}/platform/orgs/${orgId}/invites`, - "GET", - undefined + 'GET', + undefined, ); } export async function getOrganizationInvite( orgId: string, - inviteCode: string + inviteCode: string, ): Promise { return authenticatedApiCall( `${platformApiUrl}/platform/orgs/${orgId}/invites/${inviteCode}`, - "GET", - undefined + 'GET', + undefined, ); } export async function deleteOrganizationInvite( orgId: string, - inviteCode: string + inviteCode: string, ): Promise<{ message: string }> { return authenticatedApiCall( `${platformApiUrl}/platform/orgs/${orgId}/invites/${inviteCode}`, - "DELETE", - undefined + 'DELETE', + undefined, ); } -export async function listOrganizationMembers(orgId: string): Promise { +export async function listOrganizationMembers( + orgId: string, +): Promise { return authenticatedApiCall( `${platformApiUrl}/platform/orgs/${orgId}/memberships`, - "GET", - undefined + 'GET', + undefined, ); } export async function updateMemberRole( orgId: string, userId: string, - role: string + role: string, ): Promise { return authenticatedApiCall<{ role: string }, OrganizationMember>( `${platformApiUrl}/platform/orgs/${orgId}/memberships/${userId}`, - "PATCH", - { role } + 'PATCH', + { role }, ); } -export async function removeMember(orgId: string, userId: string): Promise { +export async function removeMember( + orgId: string, + userId: string, +): Promise { return authenticatedApiCall( `${platformApiUrl}/platform/orgs/${orgId}/memberships/${userId}`, - "DELETE", - undefined + 'DELETE', + undefined, ); } export async function acceptInvite(code: string): Promise<{ message: string }> { return authenticatedApiCall( `${platformApiUrl}/platform/accept_invite/${code}`, - "POST", - undefined + 'POST', + undefined, ); } // Platform User export async function platformMe(): Promise { - return authenticatedApiCall(`${platformApiUrl}/platform/me`, "GET", undefined); + return authenticatedApiCall( + `${platformApiUrl}/platform/me`, + 'GET', + undefined, + ); } /** @@ -478,10 +513,10 @@ export async function platformMe(): Promise { export async function verifyPlatformEmail(code: string): Promise { return encryptedApiCall( `${platformApiUrl}/platform/verify-email/${code}`, - "GET", + 'GET', undefined, undefined, - "Failed to verify email" + 'Failed to verify email', ); } @@ -490,12 +525,14 @@ export async function verifyPlatformEmail(code: string): Promise { * @returns A promise that resolves to a success message * @throws {Error} If the user is already verified or request fails */ -export async function requestNewPlatformVerificationCode(): Promise<{ message: string }> { +export async function requestNewPlatformVerificationCode(): Promise<{ + message: string; +}> { return authenticatedApiCall( `${platformApiUrl}/platform/request_verification`, - "POST", + 'POST', undefined, - "Failed to request new verification code" + 'Failed to request new verification code', ); } @@ -515,18 +552,18 @@ export async function requestNewPlatformVerificationCode(): Promise<{ message: s */ export async function requestPlatformPasswordReset( email: string, - hashedSecret: string + hashedSecret: string, ): Promise { const resetData = { email, - hashed_secret: hashedSecret + hashed_secret: hashedSecret, }; return encryptedApiCall( `${platformApiUrl}/platform/password-reset/request`, - "POST", + 'POST', resetData, undefined, - "Failed to request platform password reset" + 'Failed to request platform password reset', ); } @@ -550,20 +587,20 @@ export async function confirmPlatformPasswordReset( email: string, alphanumericCode: string, plaintextSecret: string, - newPassword: string + newPassword: string, ): Promise<{ message: string }> { const confirmData = { email, alphanumeric_code: alphanumericCode, plaintext_secret: plaintextSecret, - new_password: newPassword + new_password: newPassword, }; return encryptedApiCall( `${platformApiUrl}/platform/password-reset/confirm`, - "POST", + 'POST', confirmData, undefined, - "Failed to confirm platform password reset" + 'Failed to confirm platform password reset', ); } @@ -582,16 +619,16 @@ export async function confirmPlatformPasswordReset( */ export async function changePlatformPassword( currentPassword: string, - newPassword: string + newPassword: string, ): Promise<{ message: string }> { const changePasswordData = { current_password: currentPassword, - new_password: newPassword + new_password: newPassword, }; return authenticatedApiCall( `${platformApiUrl}/platform/change-password`, - "POST", + 'POST', changePasswordData, - "Failed to change platform password" + 'Failed to change platform password', ); } diff --git a/src/lib/storage.ts b/src/lib/storage.ts new file mode 100644 index 0000000..0125932 --- /dev/null +++ b/src/lib/storage.ts @@ -0,0 +1,47 @@ +/** + * Storage abstraction for OpenSecret SDK. + * + * In browser environments the SDK falls back to localStorage / sessionStorage + * automatically. Non-browser consumers (React Native, Node, tests) must call + * `configure({ storage: ... })` before any other SDK usage. + */ + +export type StorageProvider = { + persistent: { + getItem(key: string): string | null; + setItem(key: string, value: string): void; + removeItem(key: string): void; + }; + session: { + getItem(key: string): string | null; + setItem(key: string, value: string): void; + removeItem(key: string): void; + }; +}; + +let _provider: StorageProvider | null = null; + +export function setStorageProvider(provider: StorageProvider): void { + _provider = provider; +} + +export function getStorage(): StorageProvider { + if (!_provider) { + if (typeof window !== 'undefined') { + _provider = { + persistent: window.localStorage, + session: window.sessionStorage, + }; + } else { + throw new Error( + 'OpenSecret SDK: no storage provider configured. ' + + 'In non-browser environments, call configure({ storage: ... }) before using the SDK.', + ); + } + } + return _provider; +} + +export function resetStorage(): void { + _provider = null; +} diff --git a/src/lib/test/preload.ts b/src/lib/test/preload.ts index 8a95f42..0e83155 100644 --- a/src/lib/test/preload.ts +++ b/src/lib/test/preload.ts @@ -1,41 +1,35 @@ -interface StorageMock { - [key: string]: string; -} +import { setStorageProvider, type StorageProvider } from '../storage'; -function storageMock(): Storage { - const storage: StorageMock = {}; +function createMockStorage(): StorageProvider['persistent'] { + const storage: Record = {}; return { - setItem(key: string, value: string) { - storage[key] = value || ""; - }, getItem(key: string): string | null { return key in storage ? storage[key] : null; }, - removeItem(key: string) { - delete storage[key]; - }, - clear() { - Object.keys(storage).forEach((key) => delete storage[key]); + setItem(key: string, value: string): void { + storage[key] = value || ''; }, - get length(): number { - return Object.keys(storage).length; - }, - key(i: number): string | null { - const keys = Object.keys(storage); - return keys[i] || null; + removeItem(key: string): void { + delete storage[key]; }, - // Required Storage interface properties - [Symbol.iterator](): IterableIterator { - return Object.keys(storage)[Symbol.iterator](); - } }; } -global.localStorage = storageMock(); -global.sessionStorage = storageMock(); +// Configure the SDK storage provider before any other imports +setStorageProvider({ + persistent: createMockStorage(), + session: createMockStorage(), +}); + +// Still needed for other browser APIs that tests may reference // @ts-expect-error - window is not defined global.window = global; -// Import setup to configure the SDK for tests -import "./setup"; +// Configure SDK for integration tests (skipped gracefully when env vars are missing) +try { + await import('./setup'); +} catch { + // setup.ts throws when VITE_OPEN_SECRET_API_URL is not set; + // that's expected for unit tests that don't need a running server. +} diff --git a/src/lib/test/storage.test.ts b/src/lib/test/storage.test.ts new file mode 100644 index 0000000..5c60421 --- /dev/null +++ b/src/lib/test/storage.test.ts @@ -0,0 +1,149 @@ +import { describe, test, expect, beforeEach } from 'bun:test'; +import { + getStorage, + setStorageProvider, + resetStorage, + type StorageProvider, +} from '../storage'; +import { configure, resetConfig } from '../config'; + +function createMockStorage(): StorageProvider['persistent'] { + const storage: Record = {}; + return { + getItem(key: string): string | null { + return key in storage ? storage[key] : null; + }, + setItem(key: string, value: string): void { + storage[key] = value; + }, + removeItem(key: string): void { + delete storage[key]; + }, + }; +} + +describe('StorageProvider', () => { + beforeEach(() => { + resetStorage(); + }); + + test('browser environment: getStorage() returns window.localStorage / window.sessionStorage as defaults', () => { + // The test preload sets global.window = global, so typeof window !== 'undefined' + // In a browser-like environment, getStorage() should fall back to window storage + const storage = getStorage(); + expect(storage.persistent).toBe(window.localStorage); + expect(storage.session).toBe(window.sessionStorage); + }); + + test('custom provider: setStorageProvider() makes getStorage() return the custom provider', () => { + const persistent = createMockStorage(); + const session = createMockStorage(); + const customProvider: StorageProvider = { persistent, session }; + + setStorageProvider(customProvider); + + const storage = getStorage(); + expect(storage.persistent).toBe(persistent); + expect(storage.session).toBe(session); + }); + + test('custom provider via configure(): storage option wires through', () => { + const persistent = createMockStorage(); + const session = createMockStorage(); + const customProvider: StorageProvider = { persistent, session }; + + configure({ + apiUrl: 'https://example.com', + clientId: 'test-client-id', + storage: customProvider, + }); + + const storage = getStorage(); + expect(storage.persistent).toBe(persistent); + expect(storage.session).toBe(session); + + // Clean up config state + resetConfig(); + }); + + test('resetConfig() also resets the storage provider', () => { + const customProvider: StorageProvider = { + persistent: createMockStorage(), + session: createMockStorage(), + }; + + configure({ + apiUrl: 'https://example.com', + clientId: 'test-client-id', + storage: customProvider, + }); + + // Verify custom provider is active + expect(getStorage().persistent).toBe(customProvider.persistent); + + // Reset everything + resetConfig(); + resetStorage(); + + // After reset in a browser-like env, it should fall back to window storage + const storage = getStorage(); + expect(storage.persistent).toBe(window.localStorage); + }); + + test('custom provider: persistent storage stores and retrieves values', () => { + const customProvider: StorageProvider = { + persistent: createMockStorage(), + session: createMockStorage(), + }; + setStorageProvider(customProvider); + + const storage = getStorage(); + + // Initially null + expect(storage.persistent.getItem('access_token')).toBeNull(); + + // Set and get + storage.persistent.setItem('access_token', 'test-token-123'); + expect(storage.persistent.getItem('access_token')).toBe('test-token-123'); + + // Overwrite + storage.persistent.setItem('access_token', 'updated-token'); + expect(storage.persistent.getItem('access_token')).toBe('updated-token'); + + // Remove + storage.persistent.removeItem('access_token'); + expect(storage.persistent.getItem('access_token')).toBeNull(); + }); + + test('custom provider: session storage stores and retrieves values', () => { + const customProvider: StorageProvider = { + persistent: createMockStorage(), + session: createMockStorage(), + }; + setStorageProvider(customProvider); + + const storage = getStorage(); + + storage.session.setItem('sessionKey', 'test-key'); + expect(storage.session.getItem('sessionKey')).toBe('test-key'); + + storage.session.removeItem('sessionKey'); + expect(storage.session.getItem('sessionKey')).toBeNull(); + }); + + test('custom provider: persistent and session storage are isolated', () => { + const customProvider: StorageProvider = { + persistent: createMockStorage(), + session: createMockStorage(), + }; + setStorageProvider(customProvider); + + const storage = getStorage(); + + storage.persistent.setItem('token', 'persistent-value'); + storage.session.setItem('token', 'session-value'); + + expect(storage.persistent.getItem('token')).toBe('persistent-value'); + expect(storage.session.getItem('token')).toBe('session-value'); + }); +});