feat: adopt platform auth and cosmos trading control

This commit is contained in:
Saravana Achu Mac 2026-04-04 13:13:08 -07:00
parent 8f7d5358aa
commit d78aeeffc2
20 changed files with 1105 additions and 160 deletions

View File

@ -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=

View File

@ -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"

View File

@ -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',

View File

@ -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<void> {
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<void> => {
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<BotState>;
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<BotState>;
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();
});

View File

@ -51,6 +51,7 @@ export class HealthTracker {
private reconciliationNoGoReasonCounts: Record<string, number> = {};
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 {

View File

@ -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<typeof createJwtUtils> | null | undefined;
function normalizeRole(role: unknown): string | undefined {
const value = String(role || '').trim().toLowerCase();
return value || undefined;
}
function getPlatformJwtUtils(): ReturnType<typeof createJwtUtils> | 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<VerifiedTradingAuth> {
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<boolean> {
const normalizedRole = normalizeRole(tokenRole);
if (normalizedRole === 'admin' || normalizedRole === 'super_admin') {
return true;
}
return supabaseService.isAdmin(userId);
}

View File

@ -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<TradingControlDocument> | 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<TradingControlSnapshot | null> {
if (!isCosmosConfigured()) {
return null;
}
try {
const container = getContainer(CONTAINER_NAME);
const { resource } = await container.item(GLOBAL_CONTROL_ID, config.PRODUCT_ID).read<TradingControlDocument>();
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<boolean> {
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;
}
}

View File

@ -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

View File

@ -31,7 +31,7 @@ export function AuthGate({ children }: { children: ReactNode }) {
<Text style={styles.eyebrow}>BYTElyst TRADING</Text>
<Text style={styles.title}>Sign in to your trading workspace</Text>
<Text style={styles.subtitle}>
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.
</Text>
<TextInput
value={email}

167
mobile/lib/platformAuth.ts Normal file
View File

@ -0,0 +1,167 @@
import { secureSessionStorage, clearMobileSessionStorage, MOBILE_SESSION_STORAGE_KEY } from '@/lib/secureSessionStorage';
import { mobileRuntime } from '@/lib/runtime';
export interface PlatformAuthUser {
id: string;
email?: string;
role?: string;
plan?: string;
displayName?: string;
}
export interface StoredPlatformSession {
accessToken: string;
refreshToken: string;
user: PlatformAuthUser;
}
class PlatformAuthError extends Error {
status?: number;
constructor(message: string, status?: number) {
super(message);
this.name = 'PlatformAuthError';
this.status = status;
}
}
function normalizeUser(value: any): PlatformAuthUser {
return {
id: String(value?.id || value?.sub || '').trim(),
email: typeof value?.email === 'string' ? value.email : undefined,
role: typeof value?.role === 'string' ? value.role : undefined,
plan: typeof value?.plan === 'string' ? value.plan : undefined,
displayName: typeof value?.displayName === 'string' ? value.displayName : undefined,
};
}
async function platformRequest<T>(
path: string,
options?: {
method?: string;
accessToken?: string;
body?: Record<string, unknown>;
}
): Promise<T> {
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<void> {
await secureSessionStorage.setItem(MOBILE_SESSION_STORAGE_KEY, JSON.stringify(session));
}
export async function readPlatformSession(): Promise<StoredPlatformSession | null> {
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<void> {
await clearMobileSessionStorage();
}
export async function getCurrentPlatformUser(accessToken: string): Promise<PlatformAuthUser> {
const me = await platformRequest<any>('/auth/me', {
accessToken,
});
return normalizeUser(me);
}
export async function refreshPlatformSession(refreshToken: string): Promise<StoredPlatformSession> {
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<StoredPlatformSession | null> {
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<StoredPlatformSession> {
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;
}

View File

@ -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,
},
}
);

View File

@ -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",

View File

@ -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<MobileAuthContextValue | null>(null);
export function MobileAuthProvider({ children }: { children: ReactNode }) {
const [session, setSession] = useState<Session | null>(null);
const [user, setUser] = useState<User | null>(null);
const [session, setSession] = useState<StoredPlatformSession | null>(null);
const [user, setUser] = useState<PlatformAuthUser | null>(null);
const [profile, setProfile] = useState<MobileUserProfile | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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<MobileAuthContextValue>(
@ -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]
);

235
pnpm-lock.yaml generated
View File

@ -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: {}

View File

@ -35,6 +35,20 @@ interface AuthContextType {
const AuthContext = createContext<AuthContextType | undefined>(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);
}

View File

@ -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,

View File

@ -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);

View File

@ -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<string, unknown>;
};
};
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<T>(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<PlatformSession['user']>(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<string, any> | 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<T>(
path: string,
options?: {
method?: string;
accessToken?: string;
body?: Record<string, unknown>;
}
): Promise<T> {
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<PlatformSession['user']> {
const me = await platformRequest<any>('/auth/me', { accessToken });
return normalizeUser(me);
}
async function refreshPlatformSession(refreshToken: string): Promise<PlatformSession> {
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<PlatformSession | null> {
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,
};

View File

@ -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') {

View File

@ -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);
}