From 8edde6c6b0d739e269e84964963d4c15ed27d902 Mon Sep 17 00:00:00 2001 From: gudnuf Date: Wed, 27 May 2026 10:49:05 -0700 Subject: [PATCH 1/3] feat(storage): pluggable StorageProvider with required configure({ storage }) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a StorageProvider interface and a `browserStorage` helper. Every SDK consumer now passes a storage provider explicitly via configure() — no silent localStorage fallback, no hidden environment branching. Browser apps import the bundled helper: import { configure, browserStorage } from "@agicash/opensecret-sdk"; configure({ apiUrl, clientId, storage: browserStorage }); Non-browser consumers (React Native, Node, Bun, CLI, MCP) implement the two-namespace interface against their own backing store. browserStorage uses getters so importing it in a non-browser context is safe — window is only referenced if the provider is actually accessed. Updates the test setup to pass browserStorage so existing integration test fixtures keep working. --- src/lib/config.ts | 18 +++++++++++++- src/lib/index.ts | 4 +++ src/lib/storage.ts | 57 +++++++++++++++++++++++++++++++++++++++++++ src/lib/test/setup.ts | 4 ++- 4 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 src/lib/storage.ts diff --git a/src/lib/config.ts b/src/lib/config.ts index ef2c6b0..4fc3ed2 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -2,9 +2,17 @@ * Global configuration for OpenSecret SDK */ +import { setStorageProvider, resetStorage, type StorageProvider } from "./storage"; + export interface OpenSecretConfig { apiUrl: string; clientId: string; + /** + * Storage provider backing access tokens and session state. + * Browser apps can pass the exported `browserStorage` helper; + * non-browser consumers implement the interface against their own store. + */ + storage: StorageProvider; } let config: OpenSecretConfig | null = null; @@ -35,9 +43,16 @@ export function configure(options: OpenSecretConfig): void { throw new Error('OpenSecret SDK requires a non-empty clientId'); } + if (!options.storage) { + throw new Error('OpenSecret SDK requires a storage provider on configure({ storage })'); + } + + setStorageProvider(options.storage); + config = { apiUrl: options.apiUrl.replace(/\/$/, ''), // Remove trailing slash - clientId: options.clientId + clientId: options.clientId, + storage: options.storage }; } @@ -66,4 +81,5 @@ export function isConfigured(): boolean { */ export function resetConfig(): void { config = null; + resetStorage(); } diff --git a/src/lib/index.ts b/src/lib/index.ts index f74da29..67d283f 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -53,6 +53,10 @@ export type { Model } from "openai/resources/models.js"; export { configure, getConfig, isConfigured, resetConfig } from "./config"; export type { OpenSecretConfig } from "./config"; +// Export storage abstraction +export { getStorage, setStorageProvider, resetStorage, browserStorage } from "./storage"; +export type { StorageProvider } from "./storage"; + // Export API configuration export { apiConfig, type ApiContext, type ApiEndpoint } from "./apiConfig"; diff --git a/src/lib/storage.ts b/src/lib/storage.ts new file mode 100644 index 0000000..e56bb56 --- /dev/null +++ b/src/lib/storage.ts @@ -0,0 +1,57 @@ +/** + * Storage abstraction for OpenSecret SDK. + * + * Consumers must provide a StorageProvider via configure({ storage }) + * before any other SDK usage. Browser apps can pass the bundled + * `browserStorage` helper; non-browser consumers (React Native, Node, + * Bun, CLI, MCP) implement the interface against their own backing store. + */ + +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; + }; +}; + +/** + * Convenience StorageProvider that maps directly to window.localStorage and + * window.sessionStorage. Uses getters so importing this module in a non-browser + * environment is safe — `window` is only referenced if the provider is actually + * passed to configure(). + */ +export const browserStorage: StorageProvider = { + get persistent() { + return window.localStorage; + }, + get session() { + return window.sessionStorage; + } +}; + +let _provider: StorageProvider | null = null; + +export function setStorageProvider(provider: StorageProvider): void { + _provider = provider; +} + +export function getStorage(): StorageProvider { + if (!_provider) { + throw new Error( + "OpenSecret SDK: no storage provider configured. " + + "Call configure({ storage: ... }) before using the SDK. " + + "Browser apps can pass the bundled `browserStorage` helper." + ); + } + return _provider; +} + +export function resetStorage(): void { + _provider = null; +} diff --git a/src/lib/test/setup.ts b/src/lib/test/setup.ts index fff209e..9b44d3f 100644 --- a/src/lib/test/setup.ts +++ b/src/lib/test/setup.ts @@ -1,4 +1,5 @@ import { configure } from "../config"; +import { browserStorage } from "../storage"; const TEST_API_URL = process.env.VITE_OPEN_SECRET_API_URL; @@ -12,7 +13,8 @@ const TEST_CLIENT_ID = "test-client-id-for-testing"; // Configure the SDK for tests configure({ apiUrl: TEST_API_URL, - clientId: TEST_CLIENT_ID + clientId: TEST_CLIENT_ID, + storage: browserStorage }); export { TEST_API_URL, TEST_CLIENT_ID }; From 3c38f162c9b7a54184a21a6923c99ce3c37d6f48 Mon Sep 17 00:00:00 2001 From: gudnuf Date: Wed, 27 May 2026 10:49:08 -0700 Subject: [PATCH 2/3] refactor: route all storage access through getStorage() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swap all 43 direct window.localStorage / window.sessionStorage / bare localStorage / bare sessionStorage callsites across api.ts, encryptedApi.ts, ai.ts, getAttestation.ts, platformApi.ts, main.tsx, and developer.tsx for getStorage().persistent.* / getStorage().session.*. OpenSecretProvider — the React DOM component — wires the SDK's internal configure() call with the bundled browserStorage helper, since the provider is intrinsically browser-only. --- src/lib/ai.ts | 3 ++- src/lib/api.ts | 49 ++++++++++++++++++++------------------- src/lib/developer.tsx | 19 ++++++++------- src/lib/encryptedApi.ts | 3 ++- src/lib/getAttestation.ts | 9 +++---- src/lib/main.tsx | 10 ++++---- src/lib/platformApi.ts | 7 +++--- 7 files changed, 54 insertions(+), 46 deletions(-) diff --git a/src/lib/ai.ts b/src/lib/ai.ts index 3b1866f..a325386 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 { getStorage } from "./storage"; export interface CustomFetchOptions { apiKey?: string; // Optional API key to use instead of JWT token @@ -18,7 +19,7 @@ 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"); } diff --git a/src/lib/api.ts b/src/lib/api.ts index a74d573..b1751c2 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -2,6 +2,7 @@ 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; @@ -50,8 +51,8 @@ export async function fetchLogin( ); // 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; } @@ -68,8 +69,8 @@ export async function fetchGuestLogin( ); // 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; } @@ -99,8 +100,8 @@ export async function fetchSignUp( }); // 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; } @@ -120,14 +121,14 @@ export async function fetchGuestSignUp( }); // 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"); + const refresh_token = getStorage().persistent.getItem("refresh_token"); if (!refresh_token) throw new Error("No refresh token available"); const refreshData = { refresh_token }; @@ -141,8 +142,8 @@ export async function refreshToken(): Promise { "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); @@ -211,7 +212,7 @@ export async function fetchList(): Promise { } 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 { @@ -222,10 +223,10 @@ export async function fetchLogout(): Promise { } } - 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 { @@ -374,8 +375,8 @@ export async function handleGitHubCallback( ); // 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) { @@ -460,8 +461,8 @@ export async function handleGoogleCallback( ); // 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) { @@ -555,8 +556,8 @@ export async function handleAppleCallback( ); // 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) { @@ -646,8 +647,8 @@ export async function handleAppleNativeSignIn( ); // 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) { diff --git a/src/lib/developer.tsx b/src/lib/developer.tsx index 3d524f0..7a37b77 100644 --- a/src/lib/developer.tsx +++ b/src/lib/developer.tsx @@ -12,6 +12,7 @@ import { } from "./attestationForView"; import type { AttestationDocument } from "./attestation"; import { PcrConfig } from "./pcr"; +import { getStorage } from "./storage"; import type { Organization, Project, @@ -523,8 +524,8 @@ 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, @@ -574,8 +575,8 @@ 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); + getStorage().persistent.setItem("access_token", access_token); + getStorage().persistent.setItem("refresh_token", refresh_token); await fetchDeveloper(); return { access_token, refresh_token, id: "", email }; } catch (error) { @@ -592,8 +593,8 @@ export function OpenSecretDeveloper({ invite_code, name ); - window.localStorage.setItem("access_token", access_token); - window.localStorage.setItem("refresh_token", refresh_token); + getStorage().persistent.setItem("access_token", access_token); + getStorage().persistent.setItem("refresh_token", refresh_token); await fetchDeveloper(); return { access_token, refresh_token, id: "", email, name }; } catch (error) { @@ -608,7 +609,7 @@ 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); @@ -616,8 +617,8 @@ export function OpenSecretDeveloper({ 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 diff --git a/src/lib/encryptedApi.ts b/src/lib/encryptedApi.ts index ee5298c..a461026 100644 --- a/src/lib/encryptedApi.ts +++ b/src/lib/encryptedApi.ts @@ -3,6 +3,7 @@ import { getAttestation } from "./getAttestation"; import { refreshToken } from "./api"; import { platformRefreshToken } from "./platformApi"; import { apiConfig } from "./apiConfig"; +import { getStorage } from "./storage"; interface EncryptedResponse { encrypted: string; @@ -37,7 +38,7 @@ export async function authenticatedApiCall( } // Always get the latest token from localStorage - const accessToken = window.localStorage.getItem("access_token"); + const accessToken = getStorage().persistent.getItem("access_token"); if (!accessToken) { throw new Error("No access token available"); } diff --git a/src/lib/getAttestation.ts b/src/lib/getAttestation.ts index a8d1921..22be579 100644 --- a/src/lib/getAttestation.ts +++ b/src/lib/getAttestation.ts @@ -3,6 +3,7 @@ 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; @@ -30,8 +31,8 @@ export async function getAttestation( explicitApiUrl?: string ): Promise { // Check if we already have a sessionKey and sessionId in sessionstorage - const sessionKey = sessionStorage.getItem("sessionKey"); - const sessionId = sessionStorage.getItem("sessionId"); + const sessionKey = getStorage().session.getItem("sessionKey"); + const sessionId = getStorage().session.getItem("sessionId"); console.groupCollapsed("Attestation"); @@ -76,8 +77,8 @@ export async function getAttestation( if (decryptedSessionKey) { console.log("Session key decrypted successfully"); - window.sessionStorage.setItem("sessionKey", encode(decryptedSessionKey)); - window.sessionStorage.setItem("sessionId", session_id); + 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"); diff --git a/src/lib/main.tsx b/src/lib/main.tsx index a1418cd..606600b 100644 --- a/src/lib/main.tsx +++ b/src/lib/main.tsx @@ -14,6 +14,7 @@ import type { AttestationDocument } from "./attestation"; import type { LoginResponse, ThirdPartyTokenResponse, DocumentResponse } from "./api"; import { PcrConfig } from "./pcr"; import { configure } from "./config"; +import { browserStorage, getStorage } from "./storage"; export type OpenSecretAuthState = { loading: boolean; @@ -1027,8 +1028,9 @@ export function OpenSecretProvider({ ); } - // Configure the SDK with the provided values - configure({ apiUrl, clientId }); + // Configure the SDK with the provided values. OpenSecretProvider is a React DOM + // component, so browserStorage is always the correct backing store here. + configure({ apiUrl, clientId, storage: browserStorage }); }, [apiUrl, clientId]); // Create aiCustomFetch when API is configured (supports JWT or API key internally) @@ -1042,8 +1044,8 @@ export function OpenSecretProvider({ }, [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, diff --git a/src/lib/platformApi.ts b/src/lib/platformApi.ts index 2814f22..b5056a1 100644 --- a/src/lib/platformApi.ts +++ b/src/lib/platformApi.ts @@ -1,4 +1,5 @@ import { encryptedApiCall, authenticatedApiCall } from "./encryptedApi"; +import { getStorage } from "./storage"; // Platform Auth Types export type PlatformLoginResponse = { @@ -182,7 +183,7 @@ 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"); + const refresh_token = getStorage().persistent.getItem("refresh_token"); if (!refresh_token) throw new Error("No refresh token available"); const refreshData = { refresh_token }; @@ -196,8 +197,8 @@ export async function platformRefreshToken(): Promise { "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); From 11adae4de7597e94aed32515792339363332758f Mon Sep 17 00:00:00 2001 From: gudnuf Date: Wed, 27 May 2026 10:40:14 -0700 Subject: [PATCH 3/3] fix: use globalThis.crypto.randomUUID for cross-runtime compat window.crypto is browser-only; Node 19+, Bun, and Deno expose the same WebCrypto interface at globalThis.crypto. Swap the three randomUUID() callsites (getAttestation.ts, main.tsx, developer.tsx) so the SDK can generate attestation nonces on non-browser runtimes. --- src/lib/developer.tsx | 2 +- src/lib/getAttestation.ts | 2 +- src/lib/main.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lib/developer.tsx b/src/lib/developer.tsx index 7a37b77..4dda3cb 100644 --- a/src/lib/developer.tsx +++ b/src/lib/developer.tsx @@ -553,7 +553,7 @@ export function OpenSecretDeveloper({ } 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"); diff --git a/src/lib/getAttestation.ts b/src/lib/getAttestation.ts index 22be579..31869ee 100644 --- a/src/lib/getAttestation.ts +++ b/src/lib/getAttestation.ts @@ -46,7 +46,7 @@ export async function getAttestation( // 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); const document = await verifyAttestation(attestationNonce, explicitApiUrl); diff --git a/src/lib/main.tsx b/src/lib/main.tsx index 606600b..6269c84 100644 --- a/src/lib/main.tsx +++ b/src/lib/main.tsx @@ -1234,7 +1234,7 @@ export function OpenSecretProvider({ }; 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");