From d78aeeffc2c5d1c1fdfe70199e973ce1cc499be0 Mon Sep 17 00:00:00 2001 From: Saravana Achu Mac Date: Sat, 4 Apr 2026 13:13:08 -0700 Subject: [PATCH] feat: adopt platform auth and cosmos trading control --- .env.example | 14 +- backend/package.json | 4 + backend/src/config/index.ts | 8 + backend/src/services/apiServer.ts | 70 ++-- backend/src/services/healthTracker.ts | 15 + backend/src/services/platformAuthService.ts | 97 +++++ .../src/services/tradingControlRepository.ts | 76 ++++ docs/ROADMAP.md | 30 +- mobile/components/auth/AuthGate.tsx | 2 +- mobile/lib/platformAuth.ts | 167 +++++++++ mobile/lib/supabase.ts | 23 -- mobile/package.json | 1 - mobile/providers/MobileAuthProvider.tsx | 134 +++---- pnpm-lock.yaml | 235 ++++++++++++- web/src/components/AuthContext.tsx | 25 +- web/src/components/Login.tsx | 12 +- web/src/components/ResetPassword.tsx | 16 +- web/src/lib/supabaseClient.ts | 332 +++++++++++++++++- web/src/tabs/AdminTab.tsx | 2 +- web/src/tabs/PositionsTab.tsx | 2 +- 20 files changed, 1105 insertions(+), 160 deletions(-) create mode 100644 backend/src/services/platformAuthService.ts create mode 100644 backend/src/services/tradingControlRepository.ts create mode 100644 mobile/lib/platformAuth.ts delete mode 100644 mobile/lib/supabase.ts diff --git a/.env.example b/.env.example index f9611e0..4594fe1 100644 --- a/.env.example +++ b/.env.example @@ -4,10 +4,20 @@ PRODUCT_DISPLAY_NAME=ByteLyst Trading # Shared platform-service endpoint PLATFORM_API_URL=http://localhost:4003/api +PLATFORM_AUTH_ENABLED=true +PLATFORM_JWT_ISSUER=bytelyst-platform +JWT_SECRET= +PLATFORM_JWT_PUBLIC_KEY= +PLATFORM_JWT_JWKS_URL= # Product backend endpoint TRADING_API_URL=http://localhost:4018/api +# Cosmos DB control-plane storage +COSMOS_ENDPOINT= +COSMOS_KEY= +COSMOS_DATABASE=invttrdg + # Web-specific public envs NEXT_PUBLIC_PRODUCT_ID=invttrdg NEXT_PUBLIC_PLATFORM_URL=http://localhost:4003/api @@ -15,6 +25,7 @@ NEXT_PUBLIC_TRADING_API_URL=http://localhost:4018/api VITE_PRODUCT_ID=invttrdg VITE_PLATFORM_URL=http://localhost:4003/api VITE_TRADING_API_URL=http://localhost:4018/api +# Legacy data-plane fallback only. Auth no longer uses Supabase. VITE_SUPABASE_URL= VITE_SUPABASE_ANON_KEY= @@ -22,13 +33,12 @@ VITE_SUPABASE_ANON_KEY= EXPO_PUBLIC_PRODUCT_ID=invttrdg EXPO_PUBLIC_PLATFORM_URL=http://localhost:4003/api EXPO_PUBLIC_TRADING_API_URL=http://localhost:4018/api -EXPO_PUBLIC_SUPABASE_URL= -EXPO_PUBLIC_SUPABASE_ANON_KEY= # Backend envs PORT=4018 NODE_ENV=development CORS_ALLOWED_ORIGINS=http://localhost:3048,http://localhost:8081 +# Legacy data-plane fallback only. Backend auth prefers platform JWTs. SUPABASE_URL= SUPABASE_ANON_KEY= SUPABASE_SERVICE_ROLE_KEY= diff --git a/backend/package.json b/backend/package.json index 42bc079..7656422 100644 --- a/backend/package.json +++ b/backend/package.json @@ -50,6 +50,9 @@ "author": "", "license": "ISC", "dependencies": { + "@azure/cosmos": "^4.3.0", + "@bytelyst/auth": "link:../../../learning_ai/learning_ai_common_plat/packages/auth", + "@bytelyst/cosmos": "link:../../../learning_ai/learning_ai_common_plat/packages/cosmos", "@alpacahq/alpaca-trade-api": "^3.1.3", "@supabase/supabase-js": "^2.90.1", "@types/cors": "^2.8.19", @@ -59,6 +62,7 @@ "cors": "^2.8.5", "dotenv": "^17.2.3", "express": "^5.2.1", + "jose": "^6.1.2", "prom-client": "^15.1.3", "socket.io": "^4.8.3", "winston": "^3.19.0" diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index 7766864..c037962 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -6,6 +6,14 @@ dotenv.config({ override: true }); export const config = { PRODUCT_ID: process.env.PRODUCT_ID || 'invttrdg', PLATFORM_API_URL: process.env.PLATFORM_API_URL || 'http://localhost:4003/api', + PLATFORM_AUTH_ENABLED: process.env.PLATFORM_AUTH_ENABLED !== 'false', + PLATFORM_JWT_ISSUER: process.env.PLATFORM_JWT_ISSUER || 'bytelyst-platform', + PLATFORM_JWT_PUBLIC_KEY: process.env.PLATFORM_JWT_PUBLIC_KEY || '', + PLATFORM_JWT_JWKS_URL: process.env.PLATFORM_JWT_JWKS_URL || '', + JWT_SECRET: process.env.JWT_SECRET || '', + COSMOS_ENDPOINT: process.env.COSMOS_ENDPOINT || '', + COSMOS_KEY: process.env.COSMOS_KEY || '', + COSMOS_DATABASE: process.env.COSMOS_DATABASE || 'invttrdg', // Plug-and-Play Provider Selection PROVIDER: process.env.PROVIDER || 'alpaca', diff --git a/backend/src/services/apiServer.ts b/backend/src/services/apiServer.ts index d9e295a..f4702a8 100644 --- a/backend/src/services/apiServer.ts +++ b/backend/src/services/apiServer.ts @@ -11,6 +11,8 @@ import { AIClient } from './aiClient.js'; import { supabaseService } from './SupabaseService.js'; import { healthTracker, HealthSnapshot, TradingControlSnapshot } from './healthTracker.js'; import { observabilityService } from './observabilityService.js'; +import { isTradingAdmin, verifyTradingAccessToken } from './platformAuthService.js'; +import { loadGlobalTradingControl, saveGlobalTradingControl } from './tradingControlRepository.js'; import { mergeOrderSnapshots, mergePositionSnapshots } from './stateMerge.js'; import { OperationalEvent } from '../domain/operationalEvents.js'; import { runBacktest } from '../backtest/index.js'; @@ -22,6 +24,7 @@ import { interface AuthenticatedRequest extends Request { authUserId?: string; + authRole?: string; } interface RateLimitBucket { @@ -341,6 +344,9 @@ export class ApiServer { }; constructor(private port: number = 5000) { + healthTracker.subscribeTradingControl((update) => { + void this.handleTradingControlChanged(update); + }); this.loadState(); void this.restoreStateFromLatestSnapshot(); this.setupMiddleware(); @@ -595,6 +601,17 @@ export class ApiServer { return token.trim(); } + private async handleTradingControlChanged(update: TradingControlSnapshot): Promise { + this.state.health = { + ...this.state.health, + tradingControl: update, + }; + this.broadcastHealthUpdate(); + this.saveState(); + this.scheduleSnapshotWrite(); + await saveGlobalTradingControl(update); + } + private requireAuth = async (req: Request, res: Response, next: NextFunction): Promise => { const token = this.extractBearerToken(req.headers.authorization); if (!token) { @@ -602,13 +619,14 @@ export class ApiServer { return; } - const { userId, error } = await supabaseService.verifyAccessToken(token); + const { userId, role, error } = await verifyTradingAccessToken(token); if (!userId) { res.status(401).json({ error: `Unauthorized: ${error || 'invalid token'}` }); return; } (req as AuthenticatedRequest).authUserId = userId; + (req as AuthenticatedRequest).authRole = role; next(); }; @@ -620,7 +638,7 @@ export class ApiServer { } try { - const isAdmin = await supabaseService.isAdmin(userId); + const isAdmin = await isTradingAdmin(userId, (req as AuthenticatedRequest).authRole); if (!isAdmin) { res.status(403).json({ error: 'Forbidden: Admin role required' }); return; @@ -1070,25 +1088,30 @@ export class ApiServer { const ownerId = await this.resolveSnapshotOwnerId(); if (!ownerId) { logger.warn('[API] Snapshot owner not resolved; skipping Supabase restore.'); - return; + } else { + const snapshot = await supabaseService.loadLatestBotStateSnapshot(ownerId); + if (snapshot && snapshot.state) { + const restoredState = snapshot.state as Partial; + this.state = { + ...this.state, + ...restoredState, + settings: restoredState.settings || this.state.settings, + health: { + ...this.state.health, + ...(restoredState.health || {}) + } + }; + if (this.state.health.tradingControl) { + healthTracker.recordTradingControl(this.state.health.tradingControl); + } + logger.info(`[API] Restored runtime state from Supabase snapshot (user=${ownerId}).`); + } } - const snapshot = await supabaseService.loadLatestBotStateSnapshot(ownerId); - if (snapshot && snapshot.state) { - const restoredState = snapshot.state as Partial; - this.state = { - ...this.state, - ...restoredState, - settings: restoredState.settings || this.state.settings, - health: { - ...this.state.health, - ...(restoredState.health || {}) - } - }; - if (this.state.health.tradingControl) { - healthTracker.recordTradingControl(this.state.health.tradingControl); - } - logger.info(`[API] Restored runtime state from Supabase snapshot (user=${ownerId}).`); + const cosmosTradingControl = await loadGlobalTradingControl(); + if (cosmosTradingControl) { + healthTracker.recordTradingControl(cosmosTradingControl); + logger.info('[API] Restored trading control from Cosmos.'); } } catch (error: any) { logger.error('[API] Failed to restore state from Supabase snapshot:', error); @@ -1400,8 +1423,6 @@ export class ApiServer { }; healthTracker.recordTradingControl(update); - this.broadcastHealthUpdate(); - this.saveState(); observabilityService.emitEvent({ type: 'SYSTEM_ERROR', @@ -1426,8 +1447,6 @@ export class ApiServer { }; healthTracker.recordTradingControl(update); - this.broadcastHealthUpdate(); - this.saveState(); observabilityService.emitEvent({ type: 'SYSTEM_ERROR', @@ -2173,14 +2192,15 @@ RULES: return; } - const { userId, error } = await supabaseService.verifyAccessToken(authToken); + const { userId, role, error } = await verifyTradingAccessToken(authToken); if (!userId) { next(new Error(`Unauthorized: ${error || 'invalid token'}`)); return; } socket.data.userId = userId; - socket.data.isAdmin = await supabaseService.isAdmin(userId); + socket.data.authRole = role; + socket.data.isAdmin = await isTradingAdmin(userId, role); next(); }); diff --git a/backend/src/services/healthTracker.ts b/backend/src/services/healthTracker.ts index 2034df4..b448c2c 100644 --- a/backend/src/services/healthTracker.ts +++ b/backend/src/services/healthTracker.ts @@ -51,6 +51,7 @@ export class HealthTracker { private reconciliationNoGoReasonCounts: Record = {}; private reconciliationNoGoSamples: ReconciliationNoGoSample[] = []; private reconciliationIntegrityWatchdogTriggered = false; + private tradingControlListeners = new Set<(update: TradingControlSnapshot) => void>(); private tradingControl: TradingControlSnapshot = { mode: 'RUNNING', lastChangedBy: 'system', @@ -80,6 +81,20 @@ export class HealthTracker { public recordTradingControl(update: TradingControlSnapshot) { this.tradingControl = update; + for (const listener of this.tradingControlListeners) { + try { + listener(update); + } catch { + // Observers are best-effort. Health state remains authoritative. + } + } + } + + public subscribeTradingControl(listener: (update: TradingControlSnapshot) => void): () => void { + this.tradingControlListeners.add(listener); + return () => { + this.tradingControlListeners.delete(listener); + }; } public isPaused(): boolean { diff --git a/backend/src/services/platformAuthService.ts b/backend/src/services/platformAuthService.ts new file mode 100644 index 0000000..6e36421 --- /dev/null +++ b/backend/src/services/platformAuthService.ts @@ -0,0 +1,97 @@ +import { createJwtUtils } from '@bytelyst/auth'; +import { config } from '../config/index.js'; +import logger from '../utils/logger.js'; +import { supabaseService } from './SupabaseService.js'; + +export interface VerifiedTradingAuth { + userId: string | null; + role?: string; + productId?: string; + source: 'platform' | 'supabase' | null; + error?: string; +} + +let cachedJwtUtils: ReturnType | null | undefined; + +function normalizeRole(role: unknown): string | undefined { + const value = String(role || '').trim().toLowerCase(); + return value || undefined; +} + +function getPlatformJwtUtils(): ReturnType | null { + if (cachedJwtUtils !== undefined) { + return cachedJwtUtils; + } + + if (!config.PLATFORM_AUTH_ENABLED) { + cachedJwtUtils = null; + return cachedJwtUtils; + } + + const hasHs256 = Boolean(config.JWT_SECRET); + const hasRs256 = Boolean(config.PLATFORM_JWT_PUBLIC_KEY || config.PLATFORM_JWT_JWKS_URL); + + if (!hasHs256 && !hasRs256) { + cachedJwtUtils = null; + return cachedJwtUtils; + } + + cachedJwtUtils = createJwtUtils({ + issuer: config.PLATFORM_JWT_ISSUER, + algorithm: hasRs256 ? 'RS256' : 'HS256', + rsaPublicKey: config.PLATFORM_JWT_PUBLIC_KEY || undefined, + jwksUrl: config.PLATFORM_JWT_JWKS_URL || undefined, + }); + + return cachedJwtUtils; +} + +export async function verifyTradingAccessToken(token: string): Promise { + const jwtUtils = getPlatformJwtUtils(); + if (jwtUtils) { + try { + const payload = await jwtUtils.verifyToken(token); + const productId = String(payload?.productId || '').trim(); + if (!payload?.sub) { + return { userId: null, source: null, error: 'Platform token missing subject claim' }; + } + if (payload?.type && payload.type !== 'access') { + return { userId: null, source: null, error: 'Platform token is not an access token' }; + } + if (productId && productId !== config.PRODUCT_ID) { + return { userId: null, source: null, error: `Platform token product mismatch (${productId})` }; + } + + return { + userId: String(payload.sub), + role: normalizeRole(payload.role), + productId: productId || config.PRODUCT_ID, + source: 'platform', + }; + } catch (error) { + logger.warn(`[Auth] Platform token verification failed, falling back to legacy verifier: ${error instanceof Error ? error.message : 'unknown error'}`); + } + } + + const legacy = await supabaseService.verifyAccessToken(token); + if (!legacy.userId) { + return { + userId: null, + source: null, + error: legacy.error || 'invalid token', + }; + } + + return { + userId: legacy.userId, + source: 'supabase', + }; +} + +export async function isTradingAdmin(userId: string, tokenRole?: string | null): Promise { + const normalizedRole = normalizeRole(tokenRole); + if (normalizedRole === 'admin' || normalizedRole === 'super_admin') { + return true; + } + return supabaseService.isAdmin(userId); +} diff --git a/backend/src/services/tradingControlRepository.ts b/backend/src/services/tradingControlRepository.ts new file mode 100644 index 0000000..1411bab --- /dev/null +++ b/backend/src/services/tradingControlRepository.ts @@ -0,0 +1,76 @@ +import { getContainer } from '@bytelyst/cosmos'; +import { config } from '../config/index.js'; +import logger from '../utils/logger.js'; +import type { TradingControlSnapshot } from './healthTracker.js'; + +const CONTAINER_NAME = 'trading_controls'; +const GLOBAL_CONTROL_ID = 'global'; + +interface TradingControlDocument extends TradingControlSnapshot { + id: string; + productId: string; + scope: 'global'; + updatedAt: string; +} + +function isCosmosConfigured(): boolean { + return Boolean(config.COSMOS_ENDPOINT && config.COSMOS_KEY); +} + +function toTradingControlSnapshot(doc: Partial | null | undefined): TradingControlSnapshot | null { + if (!doc?.mode || !doc.lastChangedBy || !doc.lastChangedAt) { + return null; + } + + if (doc.mode !== 'RUNNING' && doc.mode !== 'PAUSED') { + return null; + } + + return { + mode: doc.mode, + lastChangedBy: doc.lastChangedBy, + lastChangedAt: Number(doc.lastChangedAt), + reason: doc.reason, + }; +} + +export async function loadGlobalTradingControl(): Promise { + if (!isCosmosConfigured()) { + return null; + } + + try { + const container = getContainer(CONTAINER_NAME); + const { resource } = await container.item(GLOBAL_CONTROL_ID, config.PRODUCT_ID).read(); + return toTradingControlSnapshot(resource); + } catch (error) { + const code = (error as { code?: number })?.code; + if (code === 404) { + return null; + } + logger.warn(`[TradingControl] Cosmos read failed: ${error instanceof Error ? error.message : 'unknown error'}`); + return null; + } +} + +export async function saveGlobalTradingControl(update: TradingControlSnapshot): Promise { + if (!isCosmosConfigured()) { + return false; + } + + try { + const container = getContainer(CONTAINER_NAME); + const doc: TradingControlDocument = { + id: GLOBAL_CONTROL_ID, + productId: config.PRODUCT_ID, + scope: 'global', + ...update, + updatedAt: new Date().toISOString(), + }; + await container.items.upsert(doc); + return true; + } catch (error) { + logger.warn(`[TradingControl] Cosmos upsert failed: ${error instanceof Error ? error.message : 'unknown error'}`); + return false; + } +} diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index ce7e4cc..ee098d6 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -28,10 +28,11 @@ It assumes: - [x] Monorepo foundation scaffolded with root workspace config, shared runtime, shared product identity, local package linking, and verification scripts - [x] Backend migrated into `backend/` and passing typecheck, build, test, and backend verification gates -- [x] Web migrated into `web/` with shared runtime, shared kill-switch gate, shared telemetry bootstrap, and normalized backend URL resolution -- [x] Mobile migrated into `mobile/` with product identity, shared runtime bootstrap, launch-time kill-switch gate, transitional Supabase auth, live backend polling plus websocket-backed updates, startup/error telemetry capture, secure session storage with invalidation handling, and explicit degraded/offline status surfacing -- [-] DRY cleanup completed for runtime/config/bootstrap concerns, shared Supabase bootstrap, and shared websocket auth helpers, but not yet for all auth/session internals -- [!] Full common-platform auth replacement remains a follow-up for web and mobile; current implementation uses transitional Supabase-backed auth to stay compatible with the backend's current JWT boundary +- [x] Web migrated into `web/` with shared runtime, shared kill-switch gate, shared telemetry bootstrap, normalized backend URL resolution, and platform-service-backed public auth via a compatibility shim +- [x] Mobile migrated into `mobile/` with product identity, shared runtime bootstrap, launch-time kill-switch gate, platform-service auth, live backend polling plus websocket-backed updates, startup/error telemetry capture, secure session storage with invalidation handling, and explicit degraded/offline status surfacing +- [x] Backend now accepts common-platform JWTs with legacy Supabase fallback and persists global trading-control state through Cosmos-backed control storage +- [-] DRY cleanup completed for runtime/config/bootstrap concerns, shared websocket auth helpers, and platform-session handling, but not yet for all data-plane persistence flows +- [!] Full common-platform data-plane replacement remains a follow-up; backend and web still retain legacy Supabase data access for trading records and configuration tables ## 3. Guiding Rules @@ -72,7 +73,7 @@ learning_ai_invt_trdg/ ### Upstream dependencies - [x] `learning_ai_common_plat` package compatibility confirmed -- [-] platform-service auth behavior confirmed +- [x] platform-service auth behavior confirmed - [x] platform-service kill-switch behavior confirmed - [x] canonical product identity schema confirmed @@ -80,7 +81,7 @@ learning_ai_invt_trdg/ - [x] backend contracts available before web integration - [x] backend contracts available before mobile integration -- [-] auth/session semantics finalized before surface wiring +- [x] auth/session semantics finalized before surface wiring - [x] kill-switch semantics finalized before release readiness ## 7. Legacy Repo Migration Map @@ -161,7 +162,7 @@ Ensure all surfaces adopt one consistent platform model for auth, kill switch, t ### Checklist - [-] Define web auth pattern using `@bytelyst/react-auth` -- [-] Define mobile auth pattern using `@bytelyst/react-native-platform-sdk` +- [x] Define mobile auth pattern using shared platform-service session contracts - [x] Define backend auth boundary and middleware strategy - [x] Define kill-switch semantics across web, mobile, and backend - [x] Define ownership split between product accessibility controls and trading-behavior controls @@ -209,9 +210,11 @@ Make backend the stable authority before web and mobile migrate heavily onto it. - [ ] Define websocket scoping model - [x] Normalize config loading and schema validation - [ ] Integrate platform-aware telemetry and diagnostics -- [-] Integrate explicit kill-switch and maintenance semantics +- [x] Integrate explicit kill-switch and maintenance semantics - [x] Assign backend enforcement for global trade halt, tenant disable, and profile disable - [x] Add runtime control endpoints +- [x] Add platform-JWT verification with legacy fallback +- [x] Add Cosmos-backed global trading-control persistence - [-] Standardize admin controls and audit logging - [ ] Define admin audit event schema - [ ] Define durable state ownership between memory, database, and exchange sync @@ -228,10 +231,10 @@ Make backend the stable authority before web and mobile migrate heavily onto it. ### Reuse from Common Platform -- [ ] Auth middleware patterns +- [x] Auth middleware patterns - [x] Config conventions - [ ] Telemetry infrastructure -- [ ] Diagnostics patterns +- [x] Diagnostics patterns ### Exit Criteria @@ -257,6 +260,7 @@ Move the web dashboard onto the new repo and onto shared platform bootstrap patt - [x] Create `web/` workspace - [ ] Define app shell - [-] Replace custom auth provider with shared auth pattern +- [x] Move public auth boundary to platform-service compatibility shim - [ ] Define route guards and role-aware rendering - [x] Move runtime config to common conventions - [x] Define product config @@ -281,7 +285,7 @@ Move the web dashboard onto the new repo and onto shared platform bootstrap patt ### Exit Criteria -- [ ] Web is no longer dependent on legacy custom auth context +- [-] Web is no longer dependent on legacy custom auth context - [ ] Web contracts align with new backend - [ ] Kill-switch and maintenance states are integrated - [ ] Web feels like one coherent product surface @@ -335,7 +339,7 @@ Build mobile as a real ecosystem surface, not a mock UI shell. ### Exit Criteria -- [-] Mobile is integrated with platform auth and kill switch +- [x] Mobile is integrated with platform auth and kill switch - [-] Mobile consumes the same product contracts as web - [x] Mobile scope is honest and operationally safe @@ -355,7 +359,7 @@ Remove duplicated implementation patterns exposed during migration. - [-] Consolidate auth/session bootstrap - [x] Consolidate product config resolution -- [-] Consolidate request headers and token propagation helpers +- [x] Consolidate request headers and token propagation helpers - [x] Consolidate telemetry boot and event fields - [x] Consolidate kill-switch UX and service-state handling - [x] Consolidate shared types for product contracts diff --git a/mobile/components/auth/AuthGate.tsx b/mobile/components/auth/AuthGate.tsx index 7d639cc..37526a0 100644 --- a/mobile/components/auth/AuthGate.tsx +++ b/mobile/components/auth/AuthGate.tsx @@ -31,7 +31,7 @@ export function AuthGate({ children }: { children: ReactNode }) { BYTElyst TRADING Sign in to your trading workspace - Mobile uses the same Supabase-backed identity boundary as the current trading backend. + Mobile now authenticates against the shared platform-service identity boundary used across the ecosystem. ( + path: string, + options?: { + method?: string; + accessToken?: string; + body?: Record; + } +): Promise { + const response = await fetch(`${mobileRuntime.platformApiUrl}${path}`, { + method: options?.method || 'GET', + headers: { + 'Content-Type': 'application/json', + 'x-product-id': mobileRuntime.productId, + ...(options?.accessToken ? { Authorization: `Bearer ${options.accessToken}` } : {}), + }, + body: options?.body ? JSON.stringify(options.body) : undefined, + }); + + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + throw new PlatformAuthError( + String((payload as { message?: string; error?: string }).message || (payload as { error?: string }).error || `HTTP ${response.status}`), + response.status + ); + } + + return payload as T; +} + +function isStoredSession(value: unknown): value is StoredPlatformSession { + const candidate = value as StoredPlatformSession | null; + return Boolean( + candidate + && typeof candidate.accessToken === 'string' + && typeof candidate.refreshToken === 'string' + && candidate.user + && typeof candidate.user.id === 'string' + ); +} + +async function writeSession(session: StoredPlatformSession): Promise { + await secureSessionStorage.setItem(MOBILE_SESSION_STORAGE_KEY, JSON.stringify(session)); +} + +export async function readPlatformSession(): Promise { + const raw = await secureSessionStorage.getItem(MOBILE_SESSION_STORAGE_KEY); + if (!raw) { + return null; + } + + try { + const parsed = JSON.parse(raw) as unknown; + return isStoredSession(parsed) ? parsed : null; + } catch { + return null; + } +} + +export async function clearPlatformSession(): Promise { + await clearMobileSessionStorage(); +} + +export async function getCurrentPlatformUser(accessToken: string): Promise { + const me = await platformRequest('/auth/me', { + accessToken, + }); + return normalizeUser(me); +} + +export async function refreshPlatformSession(refreshToken: string): Promise { + const refreshed = await platformRequest<{ accessToken: string; refreshToken: string }>('/auth/refresh', { + method: 'POST', + body: { refreshToken }, + }); + const user = await getCurrentPlatformUser(refreshed.accessToken); + const session: StoredPlatformSession = { + accessToken: refreshed.accessToken, + refreshToken: refreshed.refreshToken, + user, + }; + await writeSession(session); + return session; +} + +export async function restorePlatformSession(): Promise { + const stored = await readPlatformSession(); + if (!stored) { + return null; + } + + try { + const user = await getCurrentPlatformUser(stored.accessToken); + const session = { ...stored, user }; + await writeSession(session); + return session; + } catch (error) { + if ((error as PlatformAuthError)?.status === 401 || (error as PlatformAuthError)?.status === 403) { + try { + return await refreshPlatformSession(stored.refreshToken); + } catch { + await clearPlatformSession(); + return null; + } + } + throw error; + } +} + +export async function loginPlatformSession(email: string, password: string): Promise { + const response = await platformRequest<{ + accessToken: string; + refreshToken: string; + user: PlatformAuthUser; + }>('/auth/login', { + method: 'POST', + body: { + email, + password, + productId: mobileRuntime.productId, + }, + }); + + const session: StoredPlatformSession = { + accessToken: response.accessToken, + refreshToken: response.refreshToken, + user: normalizeUser(response.user), + }; + await writeSession(session); + return session; +} diff --git a/mobile/lib/supabase.ts b/mobile/lib/supabase.ts deleted file mode 100644 index a8c3453..0000000 --- a/mobile/lib/supabase.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { createClient } from '@supabase/supabase-js'; -import { getMobileSupabaseConfig } from '../../shared/supabase-config.js'; -import { MOBILE_SESSION_STORAGE_KEY, secureSessionStorage } from '@/lib/secureSessionStorage'; - -const supabaseConfig = getMobileSupabaseConfig(); - -if (!supabaseConfig.isConfigured) { - console.warn('[mobile] Missing Supabase environment variables'); -} - -export const mobileSupabase = createClient( - supabaseConfig.url, - supabaseConfig.anonKey, - { - auth: { - storage: secureSessionStorage, - storageKey: MOBILE_SESSION_STORAGE_KEY, - persistSession: true, - autoRefreshToken: true, - detectSessionInUrl: false, - }, - } -); diff --git a/mobile/package.json b/mobile/package.json index 14126c0..a6062d6 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -20,7 +20,6 @@ "@react-navigation/bottom-tabs": "^7.2.0", "@react-navigation/native": "^7.0.14", "@react-native-async-storage/async-storage": "^2.2.0", - "@supabase/supabase-js": "^2.58.0", "expo": "^54.0.10", "expo-blur": "~15.0.7", "expo-camera": "~17.0.8", diff --git a/mobile/providers/MobileAuthProvider.tsx b/mobile/providers/MobileAuthProvider.tsx index e7281e3..aafd9d6 100644 --- a/mobile/providers/MobileAuthProvider.tsx +++ b/mobile/providers/MobileAuthProvider.tsx @@ -1,10 +1,13 @@ import React, { createContext, useContext, useEffect, useMemo, useState } from 'react'; import type { ReactNode } from 'react'; -import type { Session, User } from '@supabase/supabase-js'; -import { mobileSupabase } from '@/lib/supabase'; -import { clearMobileSessionStorage } from '@/lib/secureSessionStorage'; -import { tableNameUsers } from '@/lib/tables'; import { mobileTelemetry, trackMobileError } from '@/lib/telemetry'; +import { + clearPlatformSession, + loginPlatformSession, + restorePlatformSession, + type PlatformAuthUser, + type StoredPlatformSession, +} from '@/lib/platformAuth'; export interface MobileUserProfile { user_id: string; @@ -16,8 +19,8 @@ export interface MobileUserProfile { } interface MobileAuthContextValue { - session: Session | null; - user: User | null; + session: StoredPlatformSession | null; + user: PlatformAuthUser | null; profile: MobileUserProfile | null; loading: boolean; error: string | null; @@ -31,8 +34,8 @@ interface MobileAuthContextValue { const MobileAuthContext = createContext(null); export function MobileAuthProvider({ children }: { children: ReactNode }) { - const [session, setSession] = useState(null); - const [user, setUser] = useState(null); + const [session, setSession] = useState(null); + const [user, setUser] = useState(null); const [profile, setProfile] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -42,17 +45,19 @@ export function MobileAuthProvider({ children }: { children: ReactNode }) { async function bootstrap() { try { - const { data } = await mobileSupabase.auth.getSession(); if (!active) { return; } - setSession(data.session ?? null); - setUser(data.session?.user ?? null); - if (data.session?.user) { + const restored = await restorePlatformSession(); + setSession(restored); + setUser(restored?.user ?? null); + if (restored?.user) { mobileTelemetry.trackEvent('info', 'auth', 'session_restored', { - userId: data.session.user.id, + userId: restored.user.id, }); - await fetchProfile(data.session.user.id); + setProfile(buildProfile(restored.user)); + setError(null); + setLoading(false); } else { setLoading(false); } @@ -65,77 +70,61 @@ export function MobileAuthProvider({ children }: { children: ReactNode }) { void bootstrap(); - const { data: authListener } = mobileSupabase.auth.onAuthStateChange((event, nextSession) => { - setSession(nextSession); - setUser(nextSession?.user ?? null); - if (nextSession?.user) { - mobileTelemetry.trackEvent('info', 'auth', 'session_changed', { - userId: nextSession.user.id, - }); - void fetchProfile(nextSession.user.id); - } else { - if (event === 'SIGNED_OUT') { - mobileTelemetry.trackEvent('info', 'auth', 'session_signed_out'); - } - setProfile(null); - setLoading(false); - } - }); - return () => { active = false; - authListener.subscription.unsubscribe(); }; }, []); - async function fetchProfile(userId: string) { - try { - const { data, error: profileError } = await mobileSupabase - .from(tableNameUsers) - .select('user_id,first_name,last_name,email,role,trade_enable') - .eq('user_id', userId) - .single(); - - if (profileError) { - setError(profileError.message); - trackMobileError('auth', 'profile_load_failed', profileError, { userId }); - } else { - setProfile((data || null) as MobileUserProfile | null); - setError(null); - } - } catch (fetchError) { - setError(fetchError instanceof Error ? fetchError.message : 'Failed to load profile'); - trackMobileError('auth', 'profile_load_failed', fetchError, { userId }); - } finally { - setLoading(false); + function buildProfile(nextUser: PlatformAuthUser | null): MobileUserProfile | null { + if (!nextUser?.id) { + return null; } + + const displayName = String(nextUser.displayName || '').trim(); + const parts = displayName ? displayName.split(/\s+/) : []; + + return { + user_id: nextUser.id, + first_name: parts[0] || undefined, + last_name: parts.slice(1).join(' ') || undefined, + email: nextUser.email, + role: nextUser.role, + trade_enable: true, + }; } async function signIn(email: string, password: string) { setLoading(true); setError(null); - const { error: authError } = await mobileSupabase.auth.signInWithPassword({ email, password }); - if (authError) { - setError(authError.message); + try { + const nextSession = await loginPlatformSession(email, password); + setSession(nextSession); + setUser(nextSession.user); + setProfile(buildProfile(nextSession.user)); + setError(null); + mobileTelemetry.trackEvent('info', 'auth', 'sign_in_succeeded', { + message: email, + userId: nextSession.user.id, + }); + return {}; + } catch (authError) { + const message = authError instanceof Error ? authError.message : 'Sign in failed'; + setError(message); trackMobileError('auth', 'sign_in_failed', authError, { email }); + return { error: message }; + } finally { setLoading(false); - return { error: authError.message }; } - mobileTelemetry.trackEvent('info', 'auth', 'sign_in_succeeded', { - message: email, - }); - return {}; } async function signOut() { setLoading(true); try { - await mobileSupabase.auth.signOut(); + await clearPlatformSession(); mobileTelemetry.trackEvent('info', 'auth', 'sign_out_succeeded', { userId: user?.id, }); } finally { - await clearMobileSessionStorage(); setProfile(null); setSession(null); setUser(null); @@ -151,11 +140,10 @@ export function MobileAuthProvider({ children }: { children: ReactNode }) { }); try { - await mobileSupabase.auth.signOut({ scope: 'local' }); + await clearPlatformSession(); } catch (invalidateError) { trackMobileError('auth', 'session_invalidation_signout_failed', invalidateError); } finally { - await clearMobileSessionStorage(); setProfile(null); setSession(null); setUser(null); @@ -165,11 +153,23 @@ export function MobileAuthProvider({ children }: { children: ReactNode }) { } async function refreshProfile() { - if (!user) { + if (!session) { return; } setLoading(true); - await fetchProfile(user.id); + try { + const restored = await restorePlatformSession(); + const nextUser = restored?.user ?? null; + setSession(restored); + setUser(nextUser); + setProfile(buildProfile(nextUser)); + setError(null); + } catch (refreshError) { + setError(refreshError instanceof Error ? refreshError.message : 'Failed to refresh profile'); + trackMobileError('auth', 'profile_refresh_failed', refreshError, { userId: user?.id || 'unknown' }); + } finally { + setLoading(false); + } } const value = useMemo( @@ -183,7 +183,7 @@ export function MobileAuthProvider({ children }: { children: ReactNode }) { signOut, invalidateSession, refreshProfile, - accessToken: session?.access_token ?? null, + accessToken: session?.accessToken ?? null, }), [session, user, profile, loading, error] ); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eb0e096..c522416 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,6 +27,15 @@ importers: '@alpacahq/alpaca-trade-api': specifier: ^3.1.3 version: 3.1.3 + '@azure/cosmos': + specifier: ^4.3.0 + version: 4.9.2(@azure/core-client@1.10.1) + '@bytelyst/auth': + specifier: link:../../../learning_ai/learning_ai_common_plat/packages/auth + version: link:../../../learning_ai/learning_ai_common_plat/packages/auth + '@bytelyst/cosmos': + specifier: link:../../../learning_ai/learning_ai_common_plat/packages/cosmos + version: link:../../../learning_ai/learning_ai_common_plat/packages/cosmos '@supabase/supabase-js': specifier: ^2.90.1 version: 2.101.1 @@ -51,6 +60,9 @@ importers: express: specifier: ^5.2.1 version: 5.2.1 + jose: + specifier: ^6.1.2 + version: 6.2.2 prom-client: specifier: ^15.1.3 version: 15.1.3 @@ -112,9 +124,6 @@ importers: '@react-navigation/native': specifier: ^7.0.14 version: 7.2.2(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) - '@supabase/supabase-js': - specifier: ^2.58.0 - version: 2.101.1 expo: specifier: ^54.0.10 version: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-webview@13.15.0(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) @@ -353,6 +362,65 @@ packages: '@asamuzakjp/nwsapi@2.3.9': resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@azure-rest/core-client@2.5.1': + resolution: {integrity: sha512-EHaOXW0RYDKS5CFffnixdyRPak5ytiCtU7uXDcP/uiY+A6jFRwNGzzJBiznkCzvi5EYpY+YWinieqHb0oY916A==} + engines: {node: '>=20.0.0'} + + '@azure/abort-controller@2.1.2': + resolution: {integrity: sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==} + engines: {node: '>=18.0.0'} + + '@azure/core-auth@1.10.1': + resolution: {integrity: sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg==} + engines: {node: '>=20.0.0'} + + '@azure/core-client@1.10.1': + resolution: {integrity: sha512-Nh5PhEOeY6PrnxNPsEHRr9eimxLwgLlpmguQaHKBinFYA/RU9+kOYVOQqOrTsCL+KSxrLLl1gD8Dk5BFW/7l/w==} + engines: {node: '>=20.0.0'} + + '@azure/core-http-compat@2.3.2': + resolution: {integrity: sha512-Tf6ltdKzOJEgxZeWLCjMxrxbodB/ZeCbzzA1A2qHbhzAjzjHoBVSUeSl/baT/oHAxhc4qdqVaDKnc2+iE932gw==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@azure/core-client': ^1.10.0 + '@azure/core-rest-pipeline': ^1.22.0 + + '@azure/core-lro@2.7.2': + resolution: {integrity: sha512-0YIpccoX8m/k00O7mDDMdJpbr6mf1yWo2dfmxt5A8XVZVVMz2SSKaEbMCeJRvgQ0IaSlqhjT47p4hVIRRy90xw==} + engines: {node: '>=18.0.0'} + + '@azure/core-paging@1.6.2': + resolution: {integrity: sha512-YKWi9YuCU04B55h25cnOYZHxXYtEvQEbKST5vqRga7hWY9ydd3FZHdeQF8pyh+acWZvppw13M/LMGx0LABUVMA==} + engines: {node: '>=18.0.0'} + + '@azure/core-rest-pipeline@1.23.0': + resolution: {integrity: sha512-Evs1INHo+jUjwHi1T6SG6Ua/LHOQBCLuKEEE6efIpt4ZOoNonaT1kP32GoOcdNDbfqsD2445CPri3MubBy5DEQ==} + engines: {node: '>=20.0.0'} + + '@azure/core-tracing@1.3.1': + resolution: {integrity: sha512-9MWKevR7Hz8kNzzPLfX4EAtGM2b8mr50HPDBvio96bURP/9C+HjdH3sBlLSNNrvRAr5/k/svoH457gB5IKpmwQ==} + engines: {node: '>=20.0.0'} + + '@azure/core-util@1.13.1': + resolution: {integrity: sha512-XPArKLzsvl0Hf0CaGyKHUyVgF7oDnhKoP85Xv6M4StF/1AhfORhZudHtOyf2s+FcbuQ9dPRAjB8J2KvRRMUK2A==} + engines: {node: '>=20.0.0'} + + '@azure/cosmos@4.9.2': + resolution: {integrity: sha512-g+n9GDm+N4iMPE3/ZfFClBvy33fE13oa60wJLKof05tEzcJF+4KsVNdjZkRNOTRBvpGGwD0CO/FGdxBuOb2+yg==} + engines: {node: '>=20.0.0'} + + '@azure/keyvault-common@2.0.0': + resolution: {integrity: sha512-wRLVaroQtOqfg60cxkzUkGKrKMsCP6uYXAOomOIysSMyt1/YM0eUn9LqieAWM8DLcU4+07Fio2YGpPeqUbpP9w==} + engines: {node: '>=18.0.0'} + + '@azure/keyvault-keys@4.10.0': + resolution: {integrity: sha512-eDT7iXoBTRZ2n3fLiftuGJFD+yjkiB1GNqzU2KbY1TLYeXeSPVTVgn2eJ5vmRTZ11978jy2Kg2wI7xa9Tyr8ag==} + engines: {node: '>=18.0.0'} + + '@azure/logger@1.3.0': + resolution: {integrity: sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA==} + engines: {node: '>=20.0.0'} + '@babel/code-frame@7.10.4': resolution: {integrity: sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==} @@ -2294,6 +2362,10 @@ packages: resolution: {integrity: sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typespec/ts-http-runtime@0.3.4': + resolution: {integrity: sha512-CI0NhTrz4EBaa0U+HaaUZrJhPoso8sG7ZFya8uQoBA57fjzrjRSv87ekCjLZOFExN+gXE/z0xuN2QfH4H2HrLQ==} + engines: {node: '>=20.0.0'} + '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} @@ -4261,6 +4333,9 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jose@6.2.2: + resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==} + js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} @@ -4995,6 +5070,9 @@ packages: resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + priorityqueuejs@2.0.0: + resolution: {integrity: sha512-19BMarhgpq3x4ccvVi8k2QpJZcymo/iFUcrhPd4V96kYGovOdTsWwy7fxChYi4QY+m2EnGBWSX9Buakz+tWNQQ==} + proc-log@4.2.0: resolution: {integrity: sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -5387,6 +5465,10 @@ packages: scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + semaphore@1.1.0: + resolution: {integrity: sha512-O4OZEaNtkMd/K0i6js9SL+gqy0ZCBMgUvlSqHKi4IBdjhe7wB8pwztUk1BbZ1fmrvpwFrPbHzqd2w5pTcJH6LA==} + engines: {node: '>=0.8.0'} + semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -6326,6 +6408,139 @@ snapshots: '@asamuzakjp/nwsapi@2.3.9': {} + '@azure-rest/core-client@2.5.1': + dependencies: + '@azure/abort-controller': 2.1.2 + '@azure/core-auth': 1.10.1 + '@azure/core-rest-pipeline': 1.23.0 + '@azure/core-tracing': 1.3.1 + '@typespec/ts-http-runtime': 0.3.4 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/abort-controller@2.1.2': + dependencies: + tslib: 2.8.1 + + '@azure/core-auth@1.10.1': + dependencies: + '@azure/abort-controller': 2.1.2 + '@azure/core-util': 1.13.1 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/core-client@1.10.1': + dependencies: + '@azure/abort-controller': 2.1.2 + '@azure/core-auth': 1.10.1 + '@azure/core-rest-pipeline': 1.23.0 + '@azure/core-tracing': 1.3.1 + '@azure/core-util': 1.13.1 + '@azure/logger': 1.3.0 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/core-http-compat@2.3.2(@azure/core-client@1.10.1)(@azure/core-rest-pipeline@1.23.0)': + dependencies: + '@azure/abort-controller': 2.1.2 + '@azure/core-client': 1.10.1 + '@azure/core-rest-pipeline': 1.23.0 + + '@azure/core-lro@2.7.2': + dependencies: + '@azure/abort-controller': 2.1.2 + '@azure/core-util': 1.13.1 + '@azure/logger': 1.3.0 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/core-paging@1.6.2': + dependencies: + tslib: 2.8.1 + + '@azure/core-rest-pipeline@1.23.0': + dependencies: + '@azure/abort-controller': 2.1.2 + '@azure/core-auth': 1.10.1 + '@azure/core-tracing': 1.3.1 + '@azure/core-util': 1.13.1 + '@azure/logger': 1.3.0 + '@typespec/ts-http-runtime': 0.3.4 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/core-tracing@1.3.1': + dependencies: + tslib: 2.8.1 + + '@azure/core-util@1.13.1': + dependencies: + '@azure/abort-controller': 2.1.2 + '@typespec/ts-http-runtime': 0.3.4 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/cosmos@4.9.2(@azure/core-client@1.10.1)': + dependencies: + '@azure/abort-controller': 2.1.2 + '@azure/core-auth': 1.10.1 + '@azure/core-rest-pipeline': 1.23.0 + '@azure/core-tracing': 1.3.1 + '@azure/core-util': 1.13.1 + '@azure/keyvault-keys': 4.10.0(@azure/core-client@1.10.1) + '@azure/logger': 1.3.0 + fast-json-stable-stringify: 2.1.0 + priorityqueuejs: 2.0.0 + semaphore: 1.1.0 + tslib: 2.8.1 + transitivePeerDependencies: + - '@azure/core-client' + - supports-color + + '@azure/keyvault-common@2.0.0': + dependencies: + '@azure/abort-controller': 2.1.2 + '@azure/core-auth': 1.10.1 + '@azure/core-client': 1.10.1 + '@azure/core-rest-pipeline': 1.23.0 + '@azure/core-tracing': 1.3.1 + '@azure/core-util': 1.13.1 + '@azure/logger': 1.3.0 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/keyvault-keys@4.10.0(@azure/core-client@1.10.1)': + dependencies: + '@azure-rest/core-client': 2.5.1 + '@azure/abort-controller': 2.1.2 + '@azure/core-auth': 1.10.1 + '@azure/core-http-compat': 2.3.2(@azure/core-client@1.10.1)(@azure/core-rest-pipeline@1.23.0) + '@azure/core-lro': 2.7.2 + '@azure/core-paging': 1.6.2 + '@azure/core-rest-pipeline': 1.23.0 + '@azure/core-tracing': 1.3.1 + '@azure/core-util': 1.13.1 + '@azure/keyvault-common': 2.0.0 + '@azure/logger': 1.3.0 + tslib: 2.8.1 + transitivePeerDependencies: + - '@azure/core-client' + - supports-color + + '@azure/logger@1.3.0': + dependencies: + '@typespec/ts-http-runtime': 0.3.4 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + '@babel/code-frame@7.10.4': dependencies: '@babel/highlight': 7.25.9 @@ -8631,6 +8846,14 @@ snapshots: '@typescript-eslint/types': 8.58.0 eslint-visitor-keys: 5.0.1 + '@typespec/ts-http-runtime@0.3.4': + dependencies: + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + '@ungap/structured-clone@1.3.0': {} '@unrs/resolver-binding-android-arm-eabi@1.11.1': @@ -10958,6 +11181,8 @@ snapshots: jiti@2.6.1: {} + jose@6.2.2: {} + js-tokens@10.0.0: {} js-tokens@4.0.0: {} @@ -11872,6 +12097,8 @@ snapshots: ansi-styles: 5.2.0 react-is: 18.3.1 + priorityqueuejs@2.0.0: {} + proc-log@4.2.0: {} progress@2.0.3: {} @@ -12368,6 +12595,8 @@ snapshots: scheduler@0.27.0: {} + semaphore@1.1.0: {} + semver@6.3.1: {} semver@7.6.3: {} diff --git a/web/src/components/AuthContext.tsx b/web/src/components/AuthContext.tsx index 61f803e..4f7c1a3 100644 --- a/web/src/components/AuthContext.tsx +++ b/web/src/components/AuthContext.tsx @@ -35,6 +35,20 @@ interface AuthContextType { const AuthContext = createContext(undefined); +const buildFallbackProfile = (authUser: User | null): UserProfile | null => { + if (!authUser?.id) return null; + const displayName = String((authUser as any)?.display_name || (authUser as any)?.user_metadata?.displayName || '').trim(); + const parts = displayName ? displayName.split(/\s+/) : []; + return { + user_id: authUser.id, + first_name: parts[0] || '', + last_name: parts.slice(1).join(' '), + email: authUser.email || '', + role: String((authUser as any)?.role || (authUser as any)?.user_metadata?.role || 'member'), + trade_enable: true, + }; +}; + export const shouldCreateDefaultProfile = (profiles: Array<{ id?: string }> | null | undefined) => !profiles || profiles.length === 0; @@ -69,8 +83,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { useEffect(() => { // 1. Get initial session supabase.auth.getSession().then(({ data: { session } }) => { - setSession(session); - setUser(session?.user ?? null); + setSession((session as Session | null) ?? null); + setUser((session?.user as User | null) ?? null); if (session?.user) { fetchProfile(session.user.id); } else { @@ -80,8 +94,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { // 2. Listen for changes const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => { - setSession(session); - setUser(session?.user ?? null); + setSession((session as Session | null) ?? null); + setUser((session?.user as User | null) ?? null); if (session?.user) { fetchProfile(session.user.id); } else { @@ -103,6 +117,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { if (error) { console.error('Error fetching user profile:', error); + setProfile(buildFallbackProfile(user)); + ensureDefaultProfile(userId); } else { setProfile(data as UserProfile); // Ensure a default trading profile exists for new users @@ -110,6 +126,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { } } catch (err) { console.error('Unexpected error fetching profile:', err); + setProfile(buildFallbackProfile(user)); } finally { setLoading(false); } diff --git a/web/src/components/Login.tsx b/web/src/components/Login.tsx index 298c55c..dfdaef2 100644 --- a/web/src/components/Login.tsx +++ b/web/src/components/Login.tsx @@ -17,12 +17,12 @@ export function Login() { setMessage(null); try { - if (isResetPassword) { - const { error } = await supabase.auth.resetPasswordForEmail(email, { - redirectTo: window.location.origin + '/reset-callback', // Optional: Handle callback if needed - }); - if (error) throw error; - setMessage('Password reset link sent! Check your email.'); + if (isResetPassword) { + const { error } = await supabase.auth.resetPasswordForEmail(email, { + redirectTo: window.location.origin + '/reset-callback', + }); + if (error) throw error; + setMessage('Password reset link sent! Check your email.'); } else if (isSignUp) { const { error } = await supabase.auth.signUp({ email, diff --git a/web/src/components/ResetPassword.tsx b/web/src/components/ResetPassword.tsx index 06952e1..80ba469 100644 --- a/web/src/components/ResetPassword.tsx +++ b/web/src/components/ResetPassword.tsx @@ -23,13 +23,15 @@ export function ResetPassword() { setMessage(null); try { - const { error } = await supabase.auth.updateUser({ password }); - if (error) throw error; - setMessage('Password updated successfully! You can now login.'); - setTimeout(() => { - window.location.href = '/'; - }, 2000); - } catch (err: any) { + const { error } = await supabase.auth.updateUser({ password }); + if (error) throw error; + setMessage('Password updated successfully! You can now login.'); + setTimeout(() => { + if (typeof window !== 'undefined') { + window.location.href = '/'; + } + }, 2000); + } catch (err: any) { setError(err.message); } finally { setLoading(false); diff --git a/web/src/lib/supabaseClient.ts b/web/src/lib/supabaseClient.ts index c69cc3c..e081f77 100644 --- a/web/src/lib/supabaseClient.ts +++ b/web/src/lib/supabaseClient.ts @@ -1,13 +1,333 @@ import { createClient } from '@supabase/supabase-js'; import { getWebSupabaseConfig } from '../../../shared/supabase-config.js'; +import { getRuntimeEnvironment } from '../../../shared/runtime.js'; const supabaseConfig = getWebSupabaseConfig(); +const runtime = getRuntimeEnvironment('web'); +const AUTH_STORAGE_PREFIX = 'invttrdg_web'; +const ACCESS_TOKEN_KEY = `${AUTH_STORAGE_PREFIX}_access_token`; +const REFRESH_TOKEN_KEY = `${AUTH_STORAGE_PREFIX}_refresh_token`; +const USER_KEY = `${AUTH_STORAGE_PREFIX}_auth_user`; +const authListeners = new Set<(event: string, session: any) => void>(); -if (!supabaseConfig.isConfigured) { - console.warn('Missing Supabase environment variables'); +type PlatformSession = { + access_token: string; + refresh_token: string; + user: { + id: string; + email?: string; + role?: string; + plan?: string; + display_name?: string; + user_metadata?: Record; + }; +}; + +class PlatformAuthError extends Error { + status?: number; + + constructor(message: string, status?: number) { + super(message); + this.name = 'PlatformAuthError'; + this.status = status; + } } -export const supabase = createClient( - supabaseConfig.url, - supabaseConfig.anonKey -); +if (!supabaseConfig.isConfigured) { + console.warn('Missing Supabase environment variables for legacy data client fallback'); +} + +const dataClient = supabaseConfig.isConfigured + ? createClient(supabaseConfig.url, supabaseConfig.anonKey) + : null; + +function parseJson(value: string | null): T | null { + if (!value) return null; + try { + return JSON.parse(value) as T; + } catch { + return null; + } +} + +function getStoredSession(): PlatformSession | null { + if (typeof window === 'undefined') return null; + const accessToken = window.localStorage.getItem(ACCESS_TOKEN_KEY); + const refreshToken = window.localStorage.getItem(REFRESH_TOKEN_KEY); + const user = parseJson(window.localStorage.getItem(USER_KEY)); + if (!accessToken || !refreshToken || !user?.id) { + return null; + } + return { + access_token: accessToken, + refresh_token: refreshToken, + user, + }; +} + +function saveSession(session: PlatformSession): void { + window.localStorage.setItem(ACCESS_TOKEN_KEY, session.access_token); + window.localStorage.setItem(REFRESH_TOKEN_KEY, session.refresh_token); + window.localStorage.setItem(USER_KEY, JSON.stringify(session.user)); +} + +function clearSession(): void { + window.localStorage.removeItem(ACCESS_TOKEN_KEY); + window.localStorage.removeItem(REFRESH_TOKEN_KEY); + window.localStorage.removeItem(USER_KEY); +} + +function emitAuthChange(event: string, session: PlatformSession | null): void { + for (const listener of authListeners) { + listener(event, session); + } +} + +function decodeJwtPayload(token: string): Record | null { + try { + const [, payload] = token.split('.'); + if (!payload) return null; + return JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/'))); + } catch { + return null; + } +} + +function isAccessTokenFresh(token: string): boolean { + const claims = decodeJwtPayload(token); + const exp = Number(claims?.exp || 0); + if (!exp) return false; + return exp > Math.floor(Date.now() / 1000) + 60; +} + +function normalizeUser(input: any): PlatformSession['user'] { + return { + id: String(input?.id || input?.sub || '').trim(), + email: typeof input?.email === 'string' ? input.email : undefined, + role: typeof input?.role === 'string' ? input.role : undefined, + plan: typeof input?.plan === 'string' ? input.plan : undefined, + display_name: typeof input?.displayName === 'string' ? input.displayName : undefined, + user_metadata: { + role: input?.role, + plan: input?.plan, + displayName: input?.displayName, + }, + }; +} + +async function platformRequest( + path: string, + options?: { + method?: string; + accessToken?: string; + body?: Record; + } +): Promise { + const response = await fetch(`${runtime.platformApiUrl}${path}`, { + method: options?.method || 'GET', + headers: { + 'Content-Type': 'application/json', + 'x-product-id': runtime.productId, + ...(options?.accessToken ? { Authorization: `Bearer ${options.accessToken}` } : {}), + }, + body: options?.body ? JSON.stringify(options.body) : undefined, + }); + + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + throw new PlatformAuthError( + String((payload as { message?: string; error?: string }).message || (payload as { error?: string }).error || `HTTP ${response.status}`), + response.status + ); + } + + return payload as T; +} + +async function getPlatformUser(accessToken: string): Promise { + const me = await platformRequest('/auth/me', { accessToken }); + return normalizeUser(me); +} + +async function refreshPlatformSession(refreshToken: string): Promise { + const refreshed = await platformRequest<{ accessToken: string; refreshToken: string }>('/auth/refresh', { + method: 'POST', + body: { refreshToken }, + }); + const user = await getPlatformUser(refreshed.accessToken); + const nextSession: PlatformSession = { + access_token: refreshed.accessToken, + refresh_token: refreshed.refreshToken, + user, + }; + saveSession(nextSession); + return nextSession; +} + +async function ensurePlatformSession(): Promise { + const stored = getStoredSession(); + if (!stored) { + return null; + } + + if (isAccessTokenFresh(stored.access_token) && stored.user?.id) { + return stored; + } + + try { + const user = await getPlatformUser(stored.access_token); + const nextSession = { ...stored, user }; + saveSession(nextSession); + return nextSession; + } catch (error) { + if ((error as PlatformAuthError)?.status === 401 || (error as PlatformAuthError)?.status === 403) { + try { + const refreshed = await refreshPlatformSession(stored.refresh_token); + emitAuthChange('TOKEN_REFRESHED', refreshed); + return refreshed; + } catch { + clearSession(); + emitAuthChange('SIGNED_OUT', null); + return null; + } + } + throw error; + } +} + +function getPasswordResetToken(): string | null { + if (typeof window === 'undefined') return null; + const url = new URL(window.location.href); + const directToken = url.searchParams.get('token'); + if (directToken) return directToken; + + const hashParams = new URLSearchParams(url.hash.replace(/^#/, '')); + return hashParams.get('token'); +} + +const auth = { + async getSession() { + return { data: { session: await ensurePlatformSession() } }; + }, + + onAuthStateChange(callback: (event: string, session: PlatformSession | null) => void) { + authListeners.add(callback); + return { + data: { + subscription: { + unsubscribe() { + authListeners.delete(callback); + } + } + } + }; + }, + + async signInWithPassword({ email, password }: { email: string; password: string; }) { + try { + const response = await platformRequest<{ + accessToken: string; + refreshToken: string; + user: unknown; + }>('/auth/login', { + method: 'POST', + body: { + email, + password, + productId: runtime.productId, + }, + }); + + const session: PlatformSession = { + access_token: response.accessToken, + refresh_token: response.refreshToken, + user: normalizeUser(response.user), + }; + saveSession(session); + emitAuthChange('SIGNED_IN', session); + return { data: { session }, error: null }; + } catch (error) { + return { data: { session: null }, error }; + } + }, + + async signUp({ email, password }: { email: string; password: string; }) { + try { + const response = await platformRequest<{ + accessToken: string; + refreshToken: string; + user: unknown; + }>('/auth/register', { + method: 'POST', + body: { + email, + password, + displayName: email.split('@')[0], + productId: runtime.productId, + }, + }); + + const session: PlatformSession = { + access_token: response.accessToken, + refresh_token: response.refreshToken, + user: normalizeUser(response.user), + }; + saveSession(session); + emitAuthChange('SIGNED_IN', session); + return { data: { session }, error: null }; + } catch (error) { + return { data: { session: null }, error }; + } + }, + + async signOut() { + clearSession(); + emitAuthChange('SIGNED_OUT', null); + return { error: null }; + }, + + async resetPasswordForEmail(email: string, _options?: { redirectTo?: string; }) { + try { + void _options; + await platformRequest('/auth/forgot-password', { + method: 'POST', + body: { + email, + productId: runtime.productId, + }, + }); + return { data: {}, error: null }; + } catch (error) { + return { data: {}, error }; + } + }, + + async updateUser({ password }: { password: string; }) { + try { + const token = getPasswordResetToken(); + if (!token) { + throw new PlatformAuthError('Missing password reset token'); + } + await platformRequest('/auth/reset-password', { + method: 'POST', + body: { + token, + newPassword: password, + }, + }); + return { data: {}, error: null }; + } catch (error) { + return { data: {}, error }; + } + }, +}; + +export const supabase = { + from: (...args: any[]) => { + if (!dataClient) { + throw new Error('Legacy Supabase data client is not configured'); + } + return (dataClient.from as any)(...args); + }, + auth, +}; diff --git a/web/src/tabs/AdminTab.tsx b/web/src/tabs/AdminTab.tsx index 010c554..96df27a 100644 --- a/web/src/tabs/AdminTab.tsx +++ b/web/src/tabs/AdminTab.tsx @@ -262,7 +262,7 @@ export const AdminTab = ({ botState }: AdminTabProps) => { .in('key', ['ENABLE_DB_SNAPSHOTS', 'DB_SNAPSHOT_INTERVAL_MS']); if (!error && data) { - data.forEach(item => { + data.forEach((item: { key: string; value: string; }) => { if (item.key === 'ENABLE_DB_SNAPSHOTS') { setDbSyncEnabled(item.value === 'true'); } else if (item.key === 'DB_SNAPSHOT_INTERVAL_MS') { diff --git a/web/src/tabs/PositionsTab.tsx b/web/src/tabs/PositionsTab.tsx index c762591..0c3d321 100644 --- a/web/src/tabs/PositionsTab.tsx +++ b/web/src/tabs/PositionsTab.tsx @@ -564,7 +564,7 @@ export const PositionsTab = ({ botState }: PositionsTabProps) => { pnlPercent: 0, stopLoss: entry.drop_threshold_for_buy, takeProfit: entry.gain_threshold_for_sell - })).filter(p => p.size > 0 && p.entryPrice > 0); + })).filter((p: { size: number; entryPrice: number; }) => p.size > 0 && p.entryPrice > 0); setManualPositions(positions); }