feat: adopt platform auth and cosmos trading control
This commit is contained in:
parent
8f7d5358aa
commit
d78aeeffc2
14
.env.example
14
.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=
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
97
backend/src/services/platformAuthService.ts
Normal file
97
backend/src/services/platformAuthService.ts
Normal 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);
|
||||
}
|
||||
76
backend/src/services/tradingControlRepository.ts
Normal file
76
backend/src/services/tradingControlRepository.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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
167
mobile/lib/platformAuth.ts
Normal 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;
|
||||
}
|
||||
@ -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,
|
||||
},
|
||||
}
|
||||
);
|
||||
@ -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",
|
||||
|
||||
@ -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
235
pnpm-lock.yaml
generated
@ -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: {}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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') {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user