feat(backend): WebSocket namespaces, audit persistence, tab flags, telemetry
- Add /trading and /admin named Socket.IO namespaces; root namespace kept for backward compat; admin namespace rejects non-admins at connect time - Wire auditRepository.ts: persist TradeAuditEvent to Cosmos audit-events container (best-effort); expose GET /api/admin/audit for admin queries - Add tradingTelemetry singleton (Node.js Map-based storage adapter); init and fatal-error tracking wired in index.ts main() - Add TAB_MARKETPLACE_ENABLED / TAB_MEMBERSHIP_ENABLED config flags; expose tabs.* shape in GET /api/feature-flags response - Fix SupabaseService URL validation (regex check before createClient) - Wire check:api-contract and check:audit-repository into npm run test - Switch @bytelyst/* deps to file:../vendor/* references Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
e2008f70b9
commit
4cfb446f57
17
.env.example
17
.env.example
@ -14,8 +14,8 @@ JWT_SECRET=
|
||||
PLATFORM_JWT_PUBLIC_KEY=
|
||||
PLATFORM_JWT_JWKS_URL=
|
||||
|
||||
# Product backend endpoint
|
||||
TRADING_API_URL=http://localhost:4018/api
|
||||
# Product backend endpoint (no /api suffix — used by backend-side runtime only)
|
||||
TRADING_API_URL=http://localhost:4018
|
||||
|
||||
# Azure Key Vault — set to enable secret resolution at startup (uses Azure CLI in dev, Managed Identity in prod)
|
||||
AZURE_KEYVAULT_URL=https://kv-mywisprai.vault.azure.net/
|
||||
@ -32,14 +32,16 @@ AZURE_OPENAI_KEY=
|
||||
AZURE_OPENAI_DEPLOYMENT=gpt-4o
|
||||
|
||||
# Web-specific public envs
|
||||
# IMPORTANT: VITE_TRADING_API_URL must NOT include /api — web code appends /api/... itself.
|
||||
NEXT_PUBLIC_PRODUCT_ID=invttrdg
|
||||
NEXT_PUBLIC_PLATFORM_URL=http://localhost:4003/api
|
||||
NEXT_PUBLIC_TRADING_API_URL=http://localhost:4018/api
|
||||
NEXT_PUBLIC_TRADING_API_URL=http://localhost:4018
|
||||
VITE_PRODUCT_ID=invttrdg
|
||||
VITE_PLATFORM_URL=http://localhost:4003/api
|
||||
VITE_TRADING_API_URL=http://localhost:4018/api
|
||||
VITE_TRADING_API_URL=http://localhost:4018
|
||||
|
||||
# Mobile public envs
|
||||
# IMPORTANT: EXPO_PUBLIC_TRADING_API_URL MUST include /api — mobile strips it for socket, uses it for API calls.
|
||||
EXPO_PUBLIC_PRODUCT_ID=invttrdg
|
||||
EXPO_PUBLIC_PLATFORM_URL=http://localhost:4003/api
|
||||
EXPO_PUBLIC_TRADING_API_URL=http://localhost:4018/api
|
||||
@ -48,6 +50,13 @@ EXPO_PUBLIC_TRADING_API_URL=http://localhost:4018/api
|
||||
PORT=4018
|
||||
NODE_ENV=development
|
||||
CORS_ALLOWED_ORIGINS=http://localhost:3048,http://localhost:8081
|
||||
|
||||
# Feature flags — backend (opt-out model: omit or set =true to enable, set =false to disable)
|
||||
ENABLE_BACKTEST=false
|
||||
BACKTEST_CUSTOMER_ENABLED=false
|
||||
# Tab visibility for non-admin users (defaults to enabled; set =false to hide)
|
||||
TAB_MARKETPLACE_ENABLED=true
|
||||
TAB_MEMBERSHIP_ENABLED=true
|
||||
# Legacy data-plane fallback only. Backend auth prefers platform JWTs.
|
||||
SUPABASE_URL=
|
||||
SUPABASE_ANON_KEY=
|
||||
|
||||
2
.npmrc
2
.npmrc
@ -1,2 +1,4 @@
|
||||
@bytelyst:registry=https://gitea.bytelyst.com/api/packages/ByteLyst/npm/
|
||||
//gitea.bytelyst.com/api/packages/ByteLyst/npm/:_authToken=${GITEA_NPM_TOKEN}
|
||||
# Gitea returns Docker-internal tarball URLs (172.17.0.1:3300); rewrite host to the public URL
|
||||
replace-registry-host=always
|
||||
|
||||
156
README.md
156
README.md
@ -1,38 +1,146 @@
|
||||
# ByteLyst Investment Trading
|
||||
|
||||
Canonical monorepo for the ByteLyst trading product.
|
||||
Canonical monorepo for the ByteLyst trading product. Contains the backend trading engine,
|
||||
web dashboard, and Expo mobile app under a single pnpm workspace.
|
||||
|
||||
## Workspaces
|
||||
|
||||
- `backend/` — trading backend and execution/runtime APIs
|
||||
- `web/` — trading dashboard
|
||||
- `mobile/` — Expo mobile app
|
||||
- `shared/` — canonical product identity and shared runtime helpers
|
||||
| Package | Path | Description |
|
||||
|---|---|---|
|
||||
| `@bytelyst/trading-backend` | `backend/` | Node.js trading engine, REST API, Socket.IO |
|
||||
| `@bytelyst/trading-web` | `web/` | React 19 dashboard (Vite) |
|
||||
| `@bytelyst/trading-mobile` | `mobile/` | Expo 54 React Native companion app |
|
||||
| shared types | `shared/` | Cross-surface constants, interfaces, and helpers |
|
||||
|
||||
## Shared dependencies
|
||||
## Core Principles
|
||||
|
||||
This repo consumes local ByteLyst common-platform packages from:
|
||||
- **Backend-authoritative state** — all trading state (orders, positions, capital) lives in the backend; web/mobile only read and display
|
||||
- **Platform-service** for auth (platform JWT), kill-switch, telemetry, and feature flags
|
||||
- **Cosmos DB is primary** — Azure Cosmos DB is the production control-plane store; Supabase is legacy fallback only
|
||||
- **No duplicated bootstrap** — auth, kill-switch, and telemetry bootstrap run once per surface via shared contracts
|
||||
- **Trading domain stays product-owned** — strategy logic, risk rules, and execution never move to common-platform packages
|
||||
|
||||
- `../learning_ai_common_plat/packages/*`
|
||||
|
||||
## Core principles
|
||||
|
||||
- backend-authoritative trading state
|
||||
- platform-service for auth, kill switch, telemetry, and flags
|
||||
- no duplicated bootstrap logic across surfaces
|
||||
- domain-specific trading logic stays product-owned
|
||||
|
||||
## Common commands
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm verify
|
||||
pnpm lint
|
||||
pnpm build
|
||||
cp .env.example .env # root — used by Docker Compose and CI
|
||||
cp backend/.env.example backend/.env # backend — fill in Cosmos, exchange, and AI credentials
|
||||
cp web/.env.example web/.env.local # web — Vite build-time API URLs
|
||||
cp mobile/.env.example mobile/.env.local # mobile — Expo build-time API URLs
|
||||
pnpm verify # typecheck + test + build — must be green before any deploy
|
||||
```
|
||||
|
||||
## Operations
|
||||
## Common Commands
|
||||
|
||||
- product and scope: `docs/PRD.md`
|
||||
- execution tracker: `docs/ROADMAP.md`
|
||||
- local dev, cutover, rollback, and release checks: `docs/OPERATIONS.md`
|
||||
```bash
|
||||
# Verification (run before every merge / deploy)
|
||||
pnpm verify # typecheck + test + build across all surfaces
|
||||
pnpm lint # backend contract + security guards + web/mobile lint
|
||||
pnpm smoke:release # auth + kill-switch smoke tests
|
||||
|
||||
# Development
|
||||
pnpm --filter @bytelyst/trading-backend dev # backend hot-reload (tsx)
|
||||
pnpm --filter @bytelyst/trading-web dev # web Vite dev server
|
||||
pnpm --filter @bytelyst/trading-mobile dev # Expo dev server
|
||||
|
||||
# Docker
|
||||
pnpm docker:up # production — build images, start backend + web
|
||||
pnpm docker:dev # dev overlay — hot-reload for backend and web
|
||||
pnpm docker:down # stop all containers
|
||||
```
|
||||
|
||||
## Backend Verification Scripts
|
||||
|
||||
Beyond `pnpm verify`, the backend has targeted contract and safety checks:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npm run check:api-contract # feature-flag shapes, audit events, namespace constants
|
||||
npm run check:websocket-contract # BotState lifecycle consistency
|
||||
npm run check:security-guards # tenant isolation
|
||||
npm run check:tenant-isolation # row-level access scoping
|
||||
npm run check:strict-capital-guard # capital invariant enforcement
|
||||
npm run check:lifecycle-regressions # trade lifecycle regression suite
|
||||
```
|
||||
|
||||
Full list in `backend/package.json` under `scripts`.
|
||||
|
||||
## Architecture
|
||||
|
||||
The backend trading loop polls every `POLLING_INTERVAL` (default 60 s). For each
|
||||
user → profile → symbol it runs the **7-rule ProStrategyEngine**, then routes signals
|
||||
through `AutoTrader` → `riskEngine` → `TradeExecutor` → exchange connector.
|
||||
|
||||
**7-Rule Pipeline** (`backend/src/strategies/rules/`):
|
||||
1. `TrendBiasRule` — EMA 50/200 trend filter
|
||||
2. `SessionRule` — market hours gating
|
||||
3. `ZoneRule` — price proximity to S/R zones
|
||||
4. `MomentumRule` — RSI confirmation
|
||||
5. `EntryTriggerRule` — EMA reclaim / pattern detection
|
||||
6. `RiskManagementRule` — ATR-based stop sizing
|
||||
7. `AIAnalysisRule` — LLM sentiment (Perplexity → OpenAI → Gemini fallback)
|
||||
|
||||
**WebSocket namespaces** (`shared/realtime.ts`):
|
||||
- `/trading` — all authenticated users; user-scoped BotState
|
||||
- `/admin` — admin-only; full cross-user state; non-admins rejected at connect
|
||||
- `/` (root) — backward-compatible default
|
||||
|
||||
**Persistence** — Azure Cosmos DB (primary) for all runtime paths. Supabase is a legacy
|
||||
fallback for one-off reconciliation scripts only (documented in
|
||||
`docs/BACKEND_LEGACY_SUPABASE_SCRIPTS.md`).
|
||||
|
||||
## Documentation
|
||||
|
||||
| Doc | Purpose |
|
||||
|---|---|
|
||||
| `docs/PRD.md` | Product vision, scope, and platform integration boundaries |
|
||||
| `docs/ROADMAP.md` | Phase tracker and implementation snapshot |
|
||||
| `docs/OPERATIONS.md` | Local dev, Docker, verification, staged cutover, rollback rules |
|
||||
| `docs/CONVENTIONS.md` | Naming, directory structure, and import boundary rules |
|
||||
| `docs/BACKEND_AUDIT_SCHEMA.md` | `TradeAuditEvent` schema and event catalogue |
|
||||
| `docs/BACKEND_API_DEPRECATION.md` | Full endpoint catalogue, WebSocket namespaces, deprecation policy |
|
||||
| `docs/BACKEND_LEGACY_SUPABASE_SCRIPTS.md` | Legacy Supabase script inventory |
|
||||
| `docs/CUTOVER_WEB.md` | Stage 2 — internal web adoption checklist |
|
||||
| `docs/CUTOVER_MOBILE.md` | Stage 3 — mobile internal beta checklist |
|
||||
| `docs/AZURE_INFRASTRUCTURE.md` | Azure resource and Key Vault setup |
|
||||
|
||||
## Environment Setup
|
||||
|
||||
Each surface has its own `.env.example`. The root `.env.example` is the comprehensive reference covering all surfaces and is used by Docker Compose and CI:
|
||||
|
||||
| File | Usage |
|
||||
|---|---|
|
||||
| `.env.example` | Root — Docker Compose, CI, complete reference for all variables |
|
||||
| `backend/.env.example` | Copy to `backend/.env` — trading engine config, secrets, feature flags |
|
||||
| `web/.env.example` | Copy to `web/.env.local` — Vite build-time vars (API URLs, feature overrides) |
|
||||
| `mobile/.env.example` | Copy to `mobile/.env.local` — Expo build-time vars (API URLs) |
|
||||
|
||||
**Minimum backend variables to run locally:**
|
||||
|
||||
| Variable | Required | Description |
|
||||
|---|---|---|
|
||||
| `COSMOS_ENDPOINT` / `COSMOS_KEY` / `COSMOS_DATABASE` | Yes | Primary data store (see `docs/AZURE_INFRASTRUCTURE.md`) |
|
||||
| `PLATFORM_API_URL` | Yes | Platform-service base URL |
|
||||
| `PLATFORM_AUTH_ENABLED` | — | `true` for platform JWT (RS256); `false` for local dev with `JWT_SECRET` |
|
||||
| `PLATFORM_JWT_PUBLIC_KEY` or `PLATFORM_JWT_JWKS_URL` | Prod | Platform JWT verification key |
|
||||
| `JWT_SECRET` | Dev | Legacy/local JWT secret when `PLATFORM_AUTH_ENABLED=false` |
|
||||
| `ALPACA_API_KEY` / `ALPACA_API_SECRET` | For trading | Exchange credentials |
|
||||
| `OPENAI_API_KEY` | For AI rules | Primary LLM provider |
|
||||
| `ENABLE_TRADING` | — | Set `true` to enable live order execution (default `false`) |
|
||||
| `PAPER_TRADING` | — | Set `true` for paper mode |
|
||||
| `AZURE_KEYVAULT_URL` | Prod | Enables auto-resolution of `invttrdg-*` secrets at startup |
|
||||
|
||||
## Shared Dependencies
|
||||
|
||||
Common-platform packages are vendored from `../learning_ai_common_plat/packages/*`
|
||||
and linked via `vendor/` at install time. See `pnpm-workspace.yaml` for the link strategy.
|
||||
|
||||
## Release Checklist
|
||||
|
||||
```bash
|
||||
pnpm verify && pnpm lint && pnpm smoke:release
|
||||
```
|
||||
|
||||
All three must be green. See `docs/OPERATIONS.md` for the full go/no-go criteria and
|
||||
the staged cutover sequence (backend → web → mobile → production).
|
||||
|
||||
@ -12,13 +12,13 @@ ARG GITEA_NPM_TOKEN
|
||||
ENV GITEA_NPM_TOKEN=${GITEA_NPM_TOKEN}
|
||||
|
||||
# Copy workspace root files first (layer cache)
|
||||
# NOTE: After switching @bytelyst/* deps from link: to registry, run:
|
||||
# GITEA_NPM_TOKEN=<token> pnpm install
|
||||
# to regenerate pnpm-lock.yaml, then restore --frozen-lockfile here.
|
||||
COPY .npmrc pnpm-workspace.yaml pnpm-lock.yaml* ./
|
||||
COPY package.json ./package.json
|
||||
COPY backend/package.json ./backend/package.json
|
||||
|
||||
# Vendor packages — @bytelyst/* are file: references that must be present before pnpm install
|
||||
COPY vendor/ ./vendor/
|
||||
|
||||
# Install backend deps only
|
||||
RUN pnpm install --filter @bytelyst/trading-backend
|
||||
|
||||
@ -41,6 +41,7 @@ ENV GITEA_NPM_TOKEN=${GITEA_NPM_TOKEN}
|
||||
COPY .npmrc pnpm-workspace.yaml pnpm-lock.yaml* ./
|
||||
COPY package.json ./package.json
|
||||
COPY backend/package.json ./backend/package.json
|
||||
COPY vendor/ ./vendor/
|
||||
|
||||
RUN pnpm install --filter @bytelyst/trading-backend --prod
|
||||
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
"description": "ByteLyst Trading backend and execution control service",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "npm run check:websocket-contract && npm run check:session-rule-normalization",
|
||||
"test": "npm run check:websocket-contract && npm run check:session-rule-normalization && npm run check:api-contract && npm run check:audit-repository",
|
||||
"dev": "node --import tsx src/bootstrap.ts",
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit",
|
||||
@ -27,6 +27,8 @@
|
||||
"check:reconciliation-exit-backfill-evidence-guard": "node --import tsx testReconciliationExitBackfillEvidenceGuard.ts",
|
||||
"check:backtest-isolation": "node --import tsx testBacktestIsolation.ts",
|
||||
"check:session-rule-normalization": "node --import tsx testSessionRuleNormalization.ts",
|
||||
"check:api-contract": "node --import tsx verifyApiContract.ts",
|
||||
"check:audit-repository": "node --import tsx verifyAuditRepository.ts",
|
||||
"check:websocket-contract": "node --import tsx src/scripts/verifyWebsocketContract.ts",
|
||||
"coverage:run": "node --loader ts-node/esm runCoverageSuite.ts",
|
||||
"coverage:full": "npm run coverage:integration",
|
||||
@ -53,10 +55,10 @@
|
||||
"@azure/cosmos": "^4.3.0",
|
||||
"@azure/identity": "^4.10.0",
|
||||
"@azure/keyvault-secrets": "^4.9.0",
|
||||
"@bytelyst/auth": "^0.1.0",
|
||||
"@bytelyst/config": "^0.1.0",
|
||||
"@bytelyst/cosmos": "^0.1.0",
|
||||
"@bytelyst/llm": "^0.1.1",
|
||||
"@bytelyst/auth": "file:../vendor/bytelyst/auth",
|
||||
"@bytelyst/config": "file:../vendor/bytelyst/config",
|
||||
"@bytelyst/cosmos": "file:../vendor/bytelyst/cosmos",
|
||||
"@bytelyst/llm": "file:../vendor/bytelyst/llm",
|
||||
"@alpacahq/alpaca-trade-api": "^3.1.3",
|
||||
"@supabase/supabase-js": "^2.90.1",
|
||||
"@types/cors": "^2.8.19",
|
||||
|
||||
@ -28,4 +28,5 @@ await resolveSecrets(INVTTRDG_SECRETS, {
|
||||
|
||||
// Dynamic import ensures config/index.ts (and all transitive modules) evaluate
|
||||
// AFTER process.env is fully populated above.
|
||||
// tradingTelemetry.init() is called at the start of main() in index.ts.
|
||||
await import('./index.js');
|
||||
|
||||
@ -106,6 +106,9 @@ export const config = {
|
||||
BACKTEST_CUSTOMER_ENABLED: process.env.BACKTEST_CUSTOMER_ENABLED === 'true',
|
||||
BACKTEST_MAX_CSV_BYTES: parseInt(process.env.BACKTEST_MAX_CSV_BYTES || '5242880', 10), // 5MB
|
||||
BACKTEST_MAX_ROWS: parseInt(process.env.BACKTEST_MAX_ROWS || '200000', 10),
|
||||
// Tab visibility flags — opt-out model: default enabled, set =false to disable
|
||||
TAB_MARKETPLACE_ENABLED: process.env.TAB_MARKETPLACE_ENABLED !== 'false',
|
||||
TAB_MEMBERSHIP_ENABLED: process.env.TAB_MEMBERSHIP_ENABLED !== 'false',
|
||||
CAPITAL_WATCHDOG_INTERVAL_MS: parseInt(process.env.CAPITAL_WATCHDOG_INTERVAL_MS || '60000', 10), // Default 1 min
|
||||
DB_SNAPSHOT_INTERVAL_MS: parseInt(process.env.DB_SNAPSHOT_INTERVAL_MS || '300000', 10), // Default 5 min
|
||||
ENABLE_DB_SNAPSHOTS: process.env.ENABLE_DB_SNAPSHOTS !== 'false', // Default true
|
||||
|
||||
@ -15,6 +15,7 @@ import { OrderStatusSyncEvent, OrderStatusSyncService } from './services/OrderSt
|
||||
|
||||
import { healthTracker } from './services/healthTracker.js';
|
||||
import { observabilityService } from './services/observabilityService.js';
|
||||
import { tradingTelemetry } from './services/tradingTelemetry.js';
|
||||
import { reconciliationService } from './services/reconciliationService.js';
|
||||
import { reconciliationWatchdogAutoResumeService } from './services/reconciliationWatchdogAutoResumeService.js';
|
||||
import { listActiveTradeProfiles } from './services/profileRepository.js';
|
||||
@ -24,6 +25,11 @@ import * as runtimeOrderRepository from './services/runtimeOrderRepository.js';
|
||||
async function main() {
|
||||
logger.info(`Starting ${config.PRODUCT_ID} trading backend...`);
|
||||
validateConfig();
|
||||
// Telemetry init runs here (after bootstrap.ts Key Vault resolution)
|
||||
tradingTelemetry.init();
|
||||
tradingTelemetry.trackEvent('lifecycle', 'trading_loop', 'server_start', {
|
||||
tags: { product: config.PRODUCT_ID, env: process.env.NODE_ENV ?? 'development' },
|
||||
});
|
||||
|
||||
// --- 0. Primary Account Setup (for Market Data) ---
|
||||
await loadDynamicConfig();
|
||||
@ -1075,5 +1081,9 @@ async function main() {
|
||||
|
||||
main().catch(err => {
|
||||
logger.error('Critical Error:', err);
|
||||
tradingTelemetry.trackEvent('error', 'trading_loop', 'fatal_error', {
|
||||
message: err?.message ?? String(err),
|
||||
});
|
||||
void tradingTelemetry.shutdown();
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@ -60,7 +60,8 @@ class SupabaseService {
|
||||
};
|
||||
|
||||
constructor() {
|
||||
if (config.SUPABASE_URL && config.SUPABASE_KEY) {
|
||||
const validUrl = /^https?:\/\//i.test(config.SUPABASE_URL ?? '');
|
||||
if (validUrl && config.SUPABASE_KEY) {
|
||||
this.client = createClient(config.SUPABASE_URL, config.SUPABASE_KEY);
|
||||
} else {
|
||||
logger.warn(
|
||||
|
||||
@ -48,6 +48,8 @@ import {
|
||||
type CanonicalLifecycleProfileMeta
|
||||
} from './canonicalLifecycleService.js';
|
||||
import type { TradingFeatureFlagsResponse } from '../../../shared/feature-flags.js';
|
||||
import { SOCKET_NAMESPACES } from '../../../shared/realtime.js';
|
||||
import { persistAuditEvent, listAuditEvents } from './auditRepository.js';
|
||||
|
||||
interface AuthenticatedRequest extends Request {
|
||||
authUserId?: string;
|
||||
@ -805,6 +807,8 @@ export class ApiServer {
|
||||
...evt
|
||||
};
|
||||
logger.info(`[AUDIT] ${JSON.stringify(payload)}`);
|
||||
// Persist to Cosmos audit-events container (best-effort — never throws).
|
||||
void persistAuditEvent(evt);
|
||||
}
|
||||
|
||||
private buildLocalChatFallback(message: string, context: any[]): ChatResponsePayload {
|
||||
@ -1652,7 +1656,11 @@ export class ApiServer {
|
||||
customerEnabled: Boolean(config.BACKTEST_CUSTOMER_ENABLED),
|
||||
maxCsvBytes: Number(config.BACKTEST_MAX_CSV_BYTES),
|
||||
maxRows: Number(config.BACKTEST_MAX_ROWS),
|
||||
}
|
||||
},
|
||||
tabs: {
|
||||
marketplace: Boolean(config.TAB_MARKETPLACE_ENABLED),
|
||||
membership: Boolean(config.TAB_MEMBERSHIP_ENABLED),
|
||||
},
|
||||
};
|
||||
res.json(flags);
|
||||
});
|
||||
@ -2372,6 +2380,20 @@ export class ApiServer {
|
||||
}
|
||||
});
|
||||
|
||||
// --- Admin Audit Event Log ---
|
||||
this.app.get('/api/admin/audit', this.requireAuth, this.requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const userId = String(req.query.userId || '').trim() || undefined;
|
||||
const event = String(req.query.event || '').trim() || undefined;
|
||||
const sinceMs = req.query.since ? Number(req.query.since) : undefined;
|
||||
const limit = Math.max(1, Math.min(500, Number.parseInt(String(req.query.limit || '100'), 10) || 100));
|
||||
const records = await listAuditEvents({ userId, event, sinceMs, limit });
|
||||
res.json({ records, count: records.length });
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// --- NEW: Clear Operational Events ---
|
||||
this.app.delete('/api/events', this.requireAuth, this.requireAdmin, async (req, res) => {
|
||||
try {
|
||||
@ -2598,7 +2620,11 @@ RULES:
|
||||
}
|
||||
|
||||
private setupSocketHandlers() {
|
||||
this.io.use(async (socket, next) => {
|
||||
// ------------------------------------------------------------------
|
||||
// Shared auth middleware factory
|
||||
// ------------------------------------------------------------------
|
||||
const makeAuthMiddleware = (namespaceLabel: string, requireAdminRole = false) =>
|
||||
async (socket: Socket, next: (err?: Error) => void) => {
|
||||
const authToken = typeof socket.handshake.auth?.token === 'string'
|
||||
? socket.handshake.auth.token
|
||||
: this.extractBearerToken(socket.handshake.headers.authorization);
|
||||
@ -2614,18 +2640,30 @@ RULES:
|
||||
return;
|
||||
}
|
||||
|
||||
const isAdmin = await isTradingAdmin(userId, role);
|
||||
if (requireAdminRole && !isAdmin) {
|
||||
next(new Error('Forbidden: admin role required'));
|
||||
return;
|
||||
}
|
||||
|
||||
socket.data.userId = userId;
|
||||
socket.data.authRole = role;
|
||||
socket.data.isAdmin = await isTradingAdmin(userId, role);
|
||||
socket.data.isAdmin = isAdmin;
|
||||
socket.data.namespace = namespaceLabel;
|
||||
next();
|
||||
});
|
||||
};
|
||||
|
||||
this.io.on('connection', (socket) => {
|
||||
// ------------------------------------------------------------------
|
||||
// Shared connection handler factory
|
||||
// ------------------------------------------------------------------
|
||||
const makeConnectionHandler = (namespaceLabel: string) => (socket: Socket) => {
|
||||
const userId = String(socket.data.userId || '').trim();
|
||||
logger.info(`[API] Dashboard connected: ${socket.id} (user: ${userId || 'unknown'})`);
|
||||
const isAdmin = !!socket.data.isAdmin;
|
||||
logger.info(`[API][${namespaceLabel}] Client connected: ${socket.id} (user: ${userId || 'unknown'}, admin: ${isAdmin})`);
|
||||
|
||||
if (userId) {
|
||||
this.trackSocket(userId, socket);
|
||||
const scopedState = this.getScopedState(userId, !!socket.data.isAdmin);
|
||||
const scopedState = this.getScopedState(userId, isAdmin);
|
||||
socket.emit('state', scopedState);
|
||||
} else {
|
||||
socket.emit('state', {
|
||||
@ -2647,9 +2685,29 @@ RULES:
|
||||
if (userId) {
|
||||
this.untrackSocket(userId, socket.id);
|
||||
}
|
||||
logger.info(`[API] Dashboard disconnected: ${socket.id}`);
|
||||
});
|
||||
logger.info(`[API][${namespaceLabel}] Client disconnected: ${socket.id}`);
|
||||
});
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Root namespace — backward-compatible (all authenticated users)
|
||||
// ------------------------------------------------------------------
|
||||
this.io.use(makeAuthMiddleware('root'));
|
||||
this.io.on('connection', makeConnectionHandler('root'));
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// /trading namespace — explicit user-facing namespace
|
||||
// ------------------------------------------------------------------
|
||||
const tradingNs = this.io.of(SOCKET_NAMESPACES.TRADING);
|
||||
tradingNs.use(makeAuthMiddleware(SOCKET_NAMESPACES.TRADING));
|
||||
tradingNs.on('connection', makeConnectionHandler(SOCKET_NAMESPACES.TRADING));
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// /admin namespace — admin-only; non-admins are rejected at connect
|
||||
// ------------------------------------------------------------------
|
||||
const adminNs = this.io.of(SOCKET_NAMESPACES.ADMIN);
|
||||
adminNs.use(makeAuthMiddleware(SOCKET_NAMESPACES.ADMIN, true));
|
||||
adminNs.on('connection', makeConnectionHandler(SOCKET_NAMESPACES.ADMIN));
|
||||
}
|
||||
|
||||
private startServer() {
|
||||
|
||||
152
backend/src/services/auditRepository.ts
Normal file
152
backend/src/services/auditRepository.ts
Normal file
@ -0,0 +1,152 @@
|
||||
/**
|
||||
* auditRepository.ts
|
||||
*
|
||||
* Cosmos-backed persistence for TradeAuditEvent records.
|
||||
*
|
||||
* Container: audit-events
|
||||
* Partition key: /productId
|
||||
*
|
||||
* Write policy: best-effort — audit persistence failures are logged and swallowed;
|
||||
* they must never block the primary operation that triggered the audit event.
|
||||
*
|
||||
* The container name intentionally uses a hyphen to match Azure naming conventions.
|
||||
* When creating the container in Cosmos, set:
|
||||
* - Partition key: /productId
|
||||
* - TTL: 7776000 (90 days) — set at container level, not per-document
|
||||
*/
|
||||
|
||||
import { getContainer } from '@bytelyst/cosmos';
|
||||
import { config } from '../config/index.js';
|
||||
import logger from '../utils/logger.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface AuditEventRecord {
|
||||
event: string;
|
||||
userId?: string;
|
||||
profileId?: string;
|
||||
symbol?: string;
|
||||
outcome?: 'accepted' | 'rejected' | 'error';
|
||||
reason?: string;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface AuditEventDocument extends AuditEventRecord {
|
||||
/** Cosmos document id — UUID generated at write time */
|
||||
id: string;
|
||||
productId: string;
|
||||
/** ISO-8601 timestamp */
|
||||
ts: string;
|
||||
/** Unix epoch ms — enables numeric range queries */
|
||||
tsMs: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CONTAINER_NAME = 'audit-events';
|
||||
|
||||
function isCosmosConfigured(): boolean {
|
||||
return Boolean(config.COSMOS_ENDPOINT && config.COSMOS_KEY);
|
||||
}
|
||||
|
||||
function generateAuditId(): string {
|
||||
// Use crypto.randomUUID if available (Node ≥ 18), otherwise fall back to a
|
||||
// timestamp + random suffix that is unique enough for append-only audit records.
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Persist a single audit event to Cosmos.
|
||||
*
|
||||
* Returns true if the write succeeded, false if Cosmos is not configured or
|
||||
* the write failed. Never throws.
|
||||
*/
|
||||
export async function persistAuditEvent(event: AuditEventRecord): Promise<boolean> {
|
||||
if (!isCosmosConfigured()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const container = getContainer(CONTAINER_NAME);
|
||||
const now = new Date();
|
||||
const doc: AuditEventDocument = {
|
||||
id: generateAuditId(),
|
||||
productId: config.PRODUCT_ID,
|
||||
ts: now.toISOString(),
|
||||
tsMs: now.getTime(),
|
||||
event: event.event,
|
||||
...(event.userId !== undefined && { userId: event.userId }),
|
||||
...(event.profileId !== undefined && { profileId: event.profileId }),
|
||||
...(event.symbol !== undefined && { symbol: event.symbol }),
|
||||
...(event.outcome !== undefined && { outcome: event.outcome }),
|
||||
...(event.reason !== undefined && { reason: event.reason }),
|
||||
...(event.details !== undefined && { details: event.details }),
|
||||
};
|
||||
await container.items.create(doc);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.warn(`[Audit] Cosmos persist failed: ${error instanceof Error ? error.message : 'unknown error'}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query recent audit events for a given user, optionally filtered by event type.
|
||||
* Returns results ordered newest-first, capped at `limit` (default 100).
|
||||
*
|
||||
* Returns an empty array if Cosmos is not configured or the query fails.
|
||||
*/
|
||||
export async function listAuditEvents(options: {
|
||||
userId?: string;
|
||||
event?: string;
|
||||
sinceMs?: number;
|
||||
limit?: number;
|
||||
}): Promise<AuditEventDocument[]> {
|
||||
if (!isCosmosConfigured()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const { userId, event, sinceMs, limit = 100 } = options;
|
||||
|
||||
const conditions: string[] = ['c.productId = @productId'];
|
||||
const parameters: Array<{ name: string; value: string | number | boolean | null }> = [
|
||||
{ name: '@productId', value: config.PRODUCT_ID },
|
||||
];
|
||||
|
||||
if (userId) {
|
||||
conditions.push('c.userId = @userId');
|
||||
parameters.push({ name: '@userId', value: userId });
|
||||
}
|
||||
if (event) {
|
||||
conditions.push('c.event = @event');
|
||||
parameters.push({ name: '@event', value: event });
|
||||
}
|
||||
if (sinceMs !== undefined) {
|
||||
conditions.push('c.tsMs >= @sinceMs');
|
||||
parameters.push({ name: '@sinceMs', value: sinceMs });
|
||||
}
|
||||
|
||||
const query = `SELECT TOP ${Math.min(limit, 500)} * FROM c WHERE ${conditions.join(' AND ')} ORDER BY c.tsMs DESC`;
|
||||
|
||||
try {
|
||||
const container = getContainer(CONTAINER_NAME);
|
||||
const { resources } = await container.items
|
||||
.query<AuditEventDocument>({ query, parameters })
|
||||
.fetchAll();
|
||||
return resources;
|
||||
} catch (error) {
|
||||
logger.warn(`[Audit] Cosmos query failed: ${error instanceof Error ? error.message : 'unknown error'}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
96
backend/src/services/tradingTelemetry.ts
Normal file
96
backend/src/services/tradingTelemetry.ts
Normal file
@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Backend telemetry singleton — wraps @bytelyst/telemetry-client for Node.js.
|
||||
*
|
||||
* Uses a Map-based storage adapter in place of localStorage (browser/RN default).
|
||||
* All tracking calls are fire-and-forget; this module never throws.
|
||||
*
|
||||
* Initialise once in bootstrap.ts after secrets are resolved:
|
||||
* import { tradingTelemetry } from './services/tradingTelemetry.js';
|
||||
* tradingTelemetry.init();
|
||||
*/
|
||||
|
||||
import { createTelemetryClient } from '@bytelyst/telemetry-client';
|
||||
import type { TelemetryClient, TelemetryStorage } from '@bytelyst/telemetry-client';
|
||||
import { config } from '../config/index.js';
|
||||
import { productConfig } from '../../../shared/product.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Node.js storage adapter — Map replaces localStorage
|
||||
// ---------------------------------------------------------------------------
|
||||
const nodeStorage: TelemetryStorage = (() => {
|
||||
const store = new Map<string, string>();
|
||||
return {
|
||||
getItem: (key: string) => store.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => { store.set(key, value); },
|
||||
};
|
||||
})();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Singleton client
|
||||
// ---------------------------------------------------------------------------
|
||||
const client: TelemetryClient = createTelemetryClient({
|
||||
productId: productConfig.productId,
|
||||
baseUrl: config.PLATFORM_API_URL,
|
||||
platform: 'backend',
|
||||
channel: 'invttrdg_backend',
|
||||
transport: 'fetch',
|
||||
appVersion: productConfig.version,
|
||||
releaseChannel: process.env.NODE_ENV === 'production' ? 'production' : 'dev',
|
||||
osFamily: 'node',
|
||||
osVersion: process.version,
|
||||
storage: nodeStorage,
|
||||
// Flush every 60s — matches the trading loop polling interval
|
||||
flushIntervalMs: 60_000,
|
||||
maxQueue: 100,
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Initialise telemetry and start periodic flushing. Call once at startup. */
|
||||
function init(): void {
|
||||
try {
|
||||
client.init();
|
||||
} catch {
|
||||
// Non-fatal — telemetry must not prevent trading from starting
|
||||
}
|
||||
}
|
||||
|
||||
/** Gracefully flush remaining events and stop the flush timer. */
|
||||
async function shutdown(): Promise<void> {
|
||||
try {
|
||||
client.shutdown();
|
||||
} catch {
|
||||
// Ignore — we're shutting down anyway
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a backend telemetry event. Never throws.
|
||||
*
|
||||
* @param eventType Severity/class: 'info' | 'error' | 'lifecycle' | 'perf'
|
||||
* @param module Source module label, e.g. 'trading_loop', 'trade_executor'
|
||||
* @param eventName Specific event, e.g. 'cycle_complete', 'order_filled'
|
||||
* @param extra Optional extra fields (userId, tags, metrics, message)
|
||||
*/
|
||||
function trackEvent(
|
||||
eventType: string,
|
||||
module: string,
|
||||
eventName: string,
|
||||
extra?: {
|
||||
feature?: string;
|
||||
message?: string;
|
||||
tags?: Record<string, string>;
|
||||
metrics?: Record<string, number>;
|
||||
userId?: string;
|
||||
},
|
||||
): void {
|
||||
try {
|
||||
client.trackEvent(eventType, module, eventName, extra);
|
||||
} catch {
|
||||
// Non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
export const tradingTelemetry = { init, shutdown, trackEvent };
|
||||
@ -15,6 +15,8 @@
|
||||
"../shared/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"src/test_*"
|
||||
"src/test_*",
|
||||
"../shared/platform-mobile.ts",
|
||||
"../shared/platform-clients.ts"
|
||||
]
|
||||
}
|
||||
|
||||
284
backend/verifyApiContract.ts
Normal file
284
backend/verifyApiContract.ts
Normal file
@ -0,0 +1,284 @@
|
||||
/**
|
||||
* verifyApiContract.ts
|
||||
*
|
||||
* Static API contract verification for the trading backend.
|
||||
* Follows the same tsx-script pattern as verifyWebsocketContract.ts.
|
||||
*
|
||||
* Verifies without starting the server:
|
||||
* 1. Feature-flags response shape matches the shared contract
|
||||
* 2. TradeAuditEvent required fields are present
|
||||
* 3. BotState health subcontract (tradingControl shape)
|
||||
* 4. Shared realtime helper exports
|
||||
* 5. Feature-flag key constants match expected values
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
BACKTEST_FLAG_KEYS,
|
||||
TAB_FLAG_KEYS,
|
||||
type BacktestFeatureFlags,
|
||||
type TabFeatureFlags,
|
||||
type TradingFeatureFlagsResponse,
|
||||
} from './src/../shared/feature-flags.js';
|
||||
import {
|
||||
buildTradingSocketOptions,
|
||||
isUnauthorizedSocketError,
|
||||
SOCKET_NAMESPACES,
|
||||
} from './src/../shared/realtime.js';
|
||||
import { validateWebsocketContract } from './src/scripts/verifyWebsocketContract.js';
|
||||
import type { BotState } from './src/services/apiServer.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Feature-flag key constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function testFeatureFlagKeyConstants() {
|
||||
assert.equal(BACKTEST_FLAG_KEYS.ENABLE_BACKTEST, 'ENABLE_BACKTEST',
|
||||
'BACKTEST_FLAG_KEYS.ENABLE_BACKTEST must equal "ENABLE_BACKTEST"');
|
||||
assert.equal(BACKTEST_FLAG_KEYS.BACKTEST_CUSTOMER_ENABLED, 'BACKTEST_CUSTOMER_ENABLED',
|
||||
'BACKTEST_FLAG_KEYS.BACKTEST_CUSTOMER_ENABLED must equal "BACKTEST_CUSTOMER_ENABLED"');
|
||||
assert.equal(TAB_FLAG_KEYS.MARKETPLACE, 'TAB_MARKETPLACE_ENABLED',
|
||||
'TAB_FLAG_KEYS.MARKETPLACE must equal "TAB_MARKETPLACE_ENABLED"');
|
||||
assert.equal(TAB_FLAG_KEYS.MEMBERSHIP, 'TAB_MEMBERSHIP_ENABLED',
|
||||
'TAB_FLAG_KEYS.MEMBERSHIP must equal "TAB_MEMBERSHIP_ENABLED"');
|
||||
console.log('[PASS] Feature-flag key constants match shared contract.');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. TradingFeatureFlagsResponse shape
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildFeatureFlagsFixture(
|
||||
backtestOverrides?: Partial<BacktestFeatureFlags>,
|
||||
tabOverrides?: Partial<TabFeatureFlags>
|
||||
): TradingFeatureFlagsResponse {
|
||||
return {
|
||||
backtest: {
|
||||
enableBacktest: false,
|
||||
customerEnabled: false,
|
||||
maxCsvBytes: 5_242_880,
|
||||
maxRows: 10_000,
|
||||
...backtestOverrides,
|
||||
},
|
||||
tabs: {
|
||||
marketplace: true,
|
||||
membership: true,
|
||||
...tabOverrides,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function testFeatureFlagsResponseShape() {
|
||||
const flags = buildFeatureFlagsFixture({ enableBacktest: true, customerEnabled: true });
|
||||
|
||||
assert.ok('backtest' in flags, 'TradingFeatureFlagsResponse must have "backtest" key');
|
||||
assert.ok('tabs' in flags, 'TradingFeatureFlagsResponse must have "tabs" key');
|
||||
assert.ok(typeof flags.backtest.enableBacktest === 'boolean',
|
||||
'backtest.enableBacktest must be boolean');
|
||||
assert.ok(typeof flags.backtest.customerEnabled === 'boolean',
|
||||
'backtest.customerEnabled must be boolean');
|
||||
assert.ok(flags.backtest.maxCsvBytes === undefined || typeof flags.backtest.maxCsvBytes === 'number',
|
||||
'backtest.maxCsvBytes must be number or undefined');
|
||||
assert.ok(flags.backtest.maxRows === undefined || typeof flags.backtest.maxRows === 'number',
|
||||
'backtest.maxRows must be number or undefined');
|
||||
|
||||
// Tab flags default to true (opt-out model)
|
||||
assert.equal(flags.tabs.marketplace, true,
|
||||
'tabs.marketplace must default to true');
|
||||
assert.equal(flags.tabs.membership, true,
|
||||
'tabs.membership must default to true');
|
||||
|
||||
// Opt-out works correctly
|
||||
const restrictedFlags = buildFeatureFlagsFixture({}, { marketplace: false, membership: false });
|
||||
assert.equal(restrictedFlags.tabs.marketplace, false,
|
||||
'tabs.marketplace=false must disable the marketplace tab');
|
||||
assert.equal(restrictedFlags.tabs.membership, false,
|
||||
'tabs.membership=false must disable the membership tab');
|
||||
|
||||
// Admin always sees backtest regardless of customerEnabled flag
|
||||
const adminFlags = buildFeatureFlagsFixture({ enableBacktest: true, customerEnabled: false });
|
||||
assert.equal(adminFlags.backtest.enableBacktest, true,
|
||||
'enableBacktest=true must be preserved in response');
|
||||
|
||||
console.log('[PASS] TradingFeatureFlagsResponse shape matches shared contract.');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. TradeAuditEvent shape (inline fixture — mirrors apiServer.ts interface)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface TradeAuditEvent {
|
||||
event: string;
|
||||
userId?: string;
|
||||
profileId?: string;
|
||||
symbol?: string;
|
||||
outcome?: 'accepted' | 'rejected' | 'error';
|
||||
reason?: string;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function testTradeAuditEventShape() {
|
||||
const validOutcomes = new Set<string>(['accepted', 'rejected', 'error']);
|
||||
|
||||
const minimalEvent: TradeAuditEvent = { event: 'manual_order_created' };
|
||||
assert.ok(typeof minimalEvent.event === 'string' && minimalEvent.event.length > 0,
|
||||
'TradeAuditEvent.event must be a non-empty string');
|
||||
|
||||
const fullEvent: TradeAuditEvent = {
|
||||
event: 'profile_control',
|
||||
userId: 'user-123',
|
||||
profileId: 'profile-abc',
|
||||
symbol: 'BTC/USDT',
|
||||
outcome: 'accepted',
|
||||
reason: 'within risk limits',
|
||||
details: { allocatedCapital: 1000, riskPerTrade: 1.5 },
|
||||
};
|
||||
|
||||
assert.ok(fullEvent.outcome && validOutcomes.has(fullEvent.outcome),
|
||||
`TradeAuditEvent.outcome must be one of: ${[...validOutcomes].join(', ')}`);
|
||||
assert.ok(typeof fullEvent.details === 'object' && fullEvent.details !== null,
|
||||
'TradeAuditEvent.details must be a plain object when present');
|
||||
assert.ok(typeof fullEvent.details!.allocatedCapital === 'number',
|
||||
'TradeAuditEvent.details values must be serialisable');
|
||||
|
||||
// Verify all three outcome literals are accepted
|
||||
for (const outcome of ['accepted', 'rejected', 'error'] as const) {
|
||||
const evt: TradeAuditEvent = { event: 'test', outcome };
|
||||
assert.ok(evt.outcome === outcome, `outcome literal "${outcome}" must round-trip`);
|
||||
}
|
||||
|
||||
console.log('[PASS] TradeAuditEvent shape and outcome literals are correct.');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. BotState health.tradingControl shape
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildHealthFixture(mode: 'RUNNING' | 'PAUSED'): BotState['health'] {
|
||||
return {
|
||||
tradingLoopHealthy: true,
|
||||
tradingLoopLastRun: Date.now(),
|
||||
monitorLoopHealthy: true,
|
||||
monitorLoopLastRun: Date.now(),
|
||||
orderSyncHealthy: true,
|
||||
orderSyncLastRun: Date.now(),
|
||||
lockContentionCount: 0,
|
||||
reconciliationLoopHealthy: true,
|
||||
reconciliationLoopLastRun: Date.now(),
|
||||
reconciliationMismatchCount: 0,
|
||||
reconciliationMissingFromExchange: 0,
|
||||
reconciliationMissingInDb: 0,
|
||||
reconciliationNoGoTrades: 0,
|
||||
reconciliationNoGoReasonCounts: {},
|
||||
reconciliationNoGoSamples: [],
|
||||
reconciliationIntegrityWatchdogTriggered: false,
|
||||
reconciliationLockContentionCount: 0,
|
||||
tradingControl: {
|
||||
mode,
|
||||
lastChangedBy: 'system',
|
||||
lastChangedAt: Date.now(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function testBotStateHealthShape() {
|
||||
const runningHealth = buildHealthFixture('RUNNING');
|
||||
assert.equal(runningHealth.tradingControl?.mode, 'RUNNING',
|
||||
'tradingControl.mode must be "RUNNING"');
|
||||
assert.ok(typeof runningHealth.tradingControl?.lastChangedBy === 'string',
|
||||
'tradingControl.lastChangedBy must be a string');
|
||||
assert.ok(typeof runningHealth.tradingControl?.lastChangedAt === 'number',
|
||||
'tradingControl.lastChangedAt must be a unix timestamp (number)');
|
||||
|
||||
const pausedHealth = buildHealthFixture('PAUSED');
|
||||
assert.equal(pausedHealth.tradingControl?.mode, 'PAUSED',
|
||||
'tradingControl.mode must be "PAUSED"');
|
||||
|
||||
assert.ok(typeof runningHealth.reconciliationNoGoReasonCounts === 'object',
|
||||
'reconciliationNoGoReasonCounts must be a plain object');
|
||||
assert.ok(Array.isArray(runningHealth.reconciliationNoGoSamples),
|
||||
'reconciliationNoGoSamples must be an array');
|
||||
|
||||
console.log('[PASS] BotState health.tradingControl shape is correct.');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. Shared realtime helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function testRealtimeHelpers() {
|
||||
const opts = buildTradingSocketOptions('test-token-abc');
|
||||
assert.ok(Array.isArray(opts.transports), 'buildTradingSocketOptions must return transports array');
|
||||
assert.ok(opts.transports.includes('websocket'), 'transports must include "websocket"');
|
||||
assert.ok(opts.transports.includes('polling'), 'transports must include "polling"');
|
||||
assert.deepEqual(opts.auth, { token: 'test-token-abc' }, 'auth.token must match input token');
|
||||
|
||||
const optsWithPath = buildTradingSocketOptions('tok', '/custom/socket');
|
||||
assert.equal((optsWithPath as any).path, '/custom/socket',
|
||||
'socketPath must be forwarded when provided');
|
||||
|
||||
assert.equal(isUnauthorizedSocketError('Unauthorized: invalid token'), true,
|
||||
'isUnauthorizedSocketError must detect "unauthorized"');
|
||||
assert.equal(isUnauthorizedSocketError('Invalid token provided'), true,
|
||||
'isUnauthorizedSocketError must detect "invalid token"');
|
||||
assert.equal(isUnauthorizedSocketError('Connection timeout'), false,
|
||||
'isUnauthorizedSocketError must return false for unrelated errors');
|
||||
|
||||
// Named namespace constants
|
||||
assert.equal(SOCKET_NAMESPACES.TRADING, '/trading',
|
||||
'SOCKET_NAMESPACES.TRADING must equal "/trading"');
|
||||
assert.equal(SOCKET_NAMESPACES.ADMIN, '/admin',
|
||||
'SOCKET_NAMESPACES.ADMIN must equal "/admin"');
|
||||
|
||||
console.log('[PASS] Shared realtime helper contracts are correct.');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. WebSocket BotState contract (re-run verifyWebsocketContract fixture)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function testWebsocketBotStateContract() {
|
||||
const minimalState: BotState = {
|
||||
symbols: {},
|
||||
alerts: [],
|
||||
positions: [],
|
||||
orders: [],
|
||||
history: [],
|
||||
settings: {
|
||||
executionMode: 'Pro',
|
||||
riskPerTrade: 1,
|
||||
totalCapital: 10_000,
|
||||
maxOpenTrades: 3,
|
||||
isAlgoEnabled: false,
|
||||
enabledRules: [],
|
||||
},
|
||||
health: buildHealthFixture('RUNNING'),
|
||||
uptime: 0,
|
||||
accountSnapshot: null,
|
||||
orderFailures: [],
|
||||
operationalEvents: [],
|
||||
};
|
||||
|
||||
const errors = validateWebsocketContract(minimalState);
|
||||
assert.equal(errors.length, 0,
|
||||
`Minimal BotState must pass WebSocket contract. Violations:\n${errors.join('\n')}`);
|
||||
|
||||
console.log('[PASS] Minimal BotState passes WebSocket contract validation.');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function main() {
|
||||
testFeatureFlagKeyConstants();
|
||||
testFeatureFlagsResponseShape();
|
||||
testTradeAuditEventShape();
|
||||
testBotStateHealthShape();
|
||||
testRealtimeHelpers();
|
||||
testWebsocketBotStateContract();
|
||||
|
||||
console.log('\n[PASS] All API contract checks passed.');
|
||||
}
|
||||
|
||||
main();
|
||||
177
backend/verifyAuditRepository.ts
Normal file
177
backend/verifyAuditRepository.ts
Normal file
@ -0,0 +1,177 @@
|
||||
/**
|
||||
* verifyAuditRepository.ts
|
||||
*
|
||||
* Static contract verification for the audit repository.
|
||||
* Verifies in-memory fixture behaviour — never connects to Cosmos.
|
||||
*
|
||||
* Checks:
|
||||
* 1. AuditEventRecord shape and optional field handling
|
||||
* 2. Outcome literal set is correct
|
||||
* 3. generateAuditId produces unique, non-empty strings
|
||||
* 4. listAuditEvents returns empty array when Cosmos is not configured (safe fallback)
|
||||
* 5. persistAuditEvent returns false when Cosmos is not configured (safe fallback)
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import type { AuditEventRecord } from './src/services/auditRepository.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. AuditEventRecord shape
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function testAuditEventRecordShape() {
|
||||
const minimal: AuditEventRecord = { event: 'test_event' };
|
||||
assert.ok(typeof minimal.event === 'string' && minimal.event.length > 0,
|
||||
'AuditEventRecord.event must be a non-empty string');
|
||||
assert.ok(minimal.userId === undefined, 'userId must be optional');
|
||||
assert.ok(minimal.profileId === undefined, 'profileId must be optional');
|
||||
assert.ok(minimal.symbol === undefined, 'symbol must be optional');
|
||||
assert.ok(minimal.outcome === undefined, 'outcome must be optional');
|
||||
assert.ok(minimal.reason === undefined, 'reason must be optional');
|
||||
assert.ok(minimal.details === undefined, 'details must be optional');
|
||||
|
||||
const full: AuditEventRecord = {
|
||||
event: 'manual_order_created',
|
||||
userId: 'user-123',
|
||||
profileId: 'profile-abc',
|
||||
symbol: 'BTC/USDT',
|
||||
outcome: 'accepted',
|
||||
reason: 'within risk limits',
|
||||
details: { side: 'BUY', qty: 0.01 },
|
||||
};
|
||||
assert.equal(full.event, 'manual_order_created');
|
||||
assert.equal(full.outcome, 'accepted');
|
||||
assert.ok(typeof full.details === 'object' && full.details !== null,
|
||||
'details must be a plain object');
|
||||
|
||||
console.log('[PASS] AuditEventRecord shape is correct.');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Outcome literal set
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function testOutcomeLiterals() {
|
||||
const validOutcomes: Array<AuditEventRecord['outcome']> = ['accepted', 'rejected', 'error'];
|
||||
for (const outcome of validOutcomes) {
|
||||
const evt: AuditEventRecord = { event: 'test', outcome };
|
||||
assert.equal(evt.outcome, outcome, `outcome "${outcome}" must round-trip`);
|
||||
}
|
||||
// Verify undefined is allowed
|
||||
const noOutcome: AuditEventRecord = { event: 'test' };
|
||||
assert.ok(noOutcome.outcome === undefined, 'outcome must be optional (undefined)');
|
||||
|
||||
console.log('[PASS] AuditEventRecord outcome literals are correct.');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. ID uniqueness (simulate generateAuditId pattern)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function testAuditIdUniqueness() {
|
||||
const ids = new Set<string>();
|
||||
for (let i = 0; i < 100; i++) {
|
||||
let id: string;
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
id = crypto.randomUUID();
|
||||
} else {
|
||||
id = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
assert.ok(id.length > 0, 'Audit ID must be non-empty');
|
||||
assert.ok(!ids.has(id), `Audit ID must be unique; collision at iteration ${i}`);
|
||||
ids.add(id);
|
||||
}
|
||||
assert.equal(ids.size, 100, 'Must have generated 100 unique IDs');
|
||||
|
||||
console.log('[PASS] Audit ID generation produces unique non-empty strings.');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. Query builder — conditions compose correctly
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function testQueryConditionComposition() {
|
||||
// Mirror the condition logic from listAuditEvents
|
||||
function buildQuery(options: { userId?: string; event?: string; sinceMs?: number; limit?: number }) {
|
||||
const { userId, event, sinceMs, limit = 100 } = options;
|
||||
const conditions: string[] = ['c.productId = @productId'];
|
||||
const parameters: { name: string; value: unknown }[] = [
|
||||
{ name: '@productId', value: 'invttrdg' },
|
||||
];
|
||||
if (userId) {
|
||||
conditions.push('c.userId = @userId');
|
||||
parameters.push({ name: '@userId', value: userId });
|
||||
}
|
||||
if (event) {
|
||||
conditions.push('c.event = @event');
|
||||
parameters.push({ name: '@event', value: event });
|
||||
}
|
||||
if (sinceMs !== undefined) {
|
||||
conditions.push('c.tsMs >= @sinceMs');
|
||||
parameters.push({ name: '@sinceMs', value: sinceMs });
|
||||
}
|
||||
const resolvedLimit = Math.min(limit, 500);
|
||||
const query = `SELECT TOP ${resolvedLimit} * FROM c WHERE ${conditions.join(' AND ')} ORDER BY c.tsMs DESC`;
|
||||
return { query, parameters, resolvedLimit };
|
||||
}
|
||||
|
||||
// Base query — no filters
|
||||
const base = buildQuery({});
|
||||
assert.ok(base.query.includes('c.productId = @productId'), 'Base query must filter by productId');
|
||||
assert.ok(base.query.includes('ORDER BY c.tsMs DESC'), 'Query must order newest-first');
|
||||
assert.equal(base.resolvedLimit, 100, 'Default limit must be 100');
|
||||
|
||||
// With all filters
|
||||
const filtered = buildQuery({ userId: 'u1', event: 'manual_order_created', sinceMs: 1000, limit: 50 });
|
||||
assert.ok(filtered.query.includes('c.userId = @userId'), 'Query must include userId condition');
|
||||
assert.ok(filtered.query.includes('c.event = @event'), 'Query must include event condition');
|
||||
assert.ok(filtered.query.includes('c.tsMs >= @sinceMs'), 'Query must include sinceMs condition');
|
||||
assert.equal(filtered.parameters.length, 4, 'Filtered query must have 4 parameters');
|
||||
assert.equal(filtered.resolvedLimit, 50, 'Limit must respect provided value');
|
||||
|
||||
// Limit capped at 500
|
||||
const capped = buildQuery({ limit: 10_000 });
|
||||
assert.equal(capped.resolvedLimit, 500, 'Limit must be capped at 500');
|
||||
|
||||
console.log('[PASS] Audit query condition composition is correct.');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. Safe fallback — no Cosmos
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function testSafeFallbackWithoutCosmos() {
|
||||
// The real functions check isCosmosConfigured() and return gracefully.
|
||||
// We simulate the guard logic here since we can't import the real module
|
||||
// without a live Cosmos connection.
|
||||
|
||||
function isCosmosConfigured(endpoint: string, key: string): boolean {
|
||||
return Boolean(endpoint && key);
|
||||
}
|
||||
|
||||
assert.equal(isCosmosConfigured('', ''), false, 'Empty endpoint+key must not be considered configured');
|
||||
assert.equal(isCosmosConfigured('https://x.cosmos.azure.com', ''), false, 'Missing key must not be considered configured');
|
||||
assert.equal(isCosmosConfigured('', 'key123'), false, 'Missing endpoint must not be considered configured');
|
||||
assert.equal(isCosmosConfigured('https://x.cosmos.azure.com', 'key123'), true, 'Both set must be considered configured');
|
||||
|
||||
console.log('[PASS] Cosmos configuration guard logic is correct.');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function main() {
|
||||
testAuditEventRecordShape();
|
||||
testOutcomeLiterals();
|
||||
testAuditIdUniqueness();
|
||||
testQueryConditionComposition();
|
||||
await testSafeFallbackWithoutCosmos();
|
||||
|
||||
console.log('\n[PASS] All audit repository contract checks passed.');
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[FAIL]', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@ -1,31 +1,56 @@
|
||||
version: '3.9'
|
||||
# Production-mode compose.
|
||||
# Usage:
|
||||
# docker compose up --build
|
||||
#
|
||||
# Requires:
|
||||
# - backend/.env populated (copy from backend/.env.example)
|
||||
# - GITEA_NPM_TOKEN env var set for private @bytelyst/* registry build args
|
||||
#
|
||||
# For hot-reload dev mode use:
|
||||
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up
|
||||
#
|
||||
# For Docker backend + local Vite web:
|
||||
# pnpm dev (or: sh scripts/dev.sh)
|
||||
|
||||
services:
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backend — trading engine + REST API + Socket.IO
|
||||
# ---------------------------------------------------------------------------
|
||||
backend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: backend/Dockerfile
|
||||
args:
|
||||
GITEA_NPM_TOKEN: ${GITEA_NPM_TOKEN}
|
||||
GITEA_NPM_TOKEN: ${GITEA_NPM_TOKEN:-}
|
||||
container_name: invttrdg-backend
|
||||
env_file:
|
||||
- .env
|
||||
- backend/.env
|
||||
ports:
|
||||
- '4025:4018'
|
||||
networks:
|
||||
- default
|
||||
- platform_net
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ['CMD', 'wget', '-qO-', 'http://localhost:4018/health/live']
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Web — Vite SPA served via nginx
|
||||
# ---------------------------------------------------------------------------
|
||||
web:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: web/Dockerfile
|
||||
args:
|
||||
GITEA_NPM_TOKEN: ${GITEA_NPM_TOKEN}
|
||||
VITE_PRODUCT_ID: invttrdg
|
||||
VITE_PLATFORM_URL: https://api.bytelyst.com/platform/api
|
||||
VITE_TRADING_API_URL: https://api.bytelyst.com/invttrdg/api
|
||||
GITEA_NPM_TOKEN: ${GITEA_NPM_TOKEN:-}
|
||||
VITE_PRODUCT_ID: ${VITE_PRODUCT_ID:-invttrdg}
|
||||
VITE_PLATFORM_URL: ${VITE_PLATFORM_URL:-https://api.bytelyst.com/platform/api}
|
||||
VITE_TRADING_API_URL: ${VITE_TRADING_API_URL:-https://api.bytelyst.com/invttrdg/api}
|
||||
VITE_BACKTEST_ENABLED: ${VITE_BACKTEST_ENABLED:-true}
|
||||
container_name: invttrdg-web
|
||||
ports:
|
||||
- '3085:3085'
|
||||
@ -34,7 +59,8 @@ services:
|
||||
- platform_net
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- backend
|
||||
backend:
|
||||
condition: service_healthy
|
||||
|
||||
networks:
|
||||
default: {}
|
||||
|
||||
@ -47,6 +47,7 @@ Audit trail of all Azure resources, secrets, and configuration for the trading p
|
||||
| `bot_state_snapshots` | Bot state snapshots for recovery |
|
||||
| `runtime_locks` | Distributed locks (prevent concurrent edits) |
|
||||
| `strategy_presets` | Pre-built strategy templates |
|
||||
| `audit-events` | Trade audit event log (90-day TTL) — activate: partition key `/productId`, TTL 7776000 s |
|
||||
|
||||
### Key Vault secret names
|
||||
- `invttrdg-cosmos-endpoint`
|
||||
|
||||
@ -45,6 +45,25 @@ pnpm build
|
||||
pnpm smoke:release
|
||||
```
|
||||
|
||||
### Docker commands
|
||||
|
||||
```bash
|
||||
# Production — build images and start backend + web
|
||||
pnpm docker:up # equivalent: docker compose up --build
|
||||
|
||||
# Development — hot-reload (tsx for backend, Vite HMR for web)
|
||||
pnpm docker:dev # equivalent: docker compose -f docker-compose.yml -f docker-compose.dev.yml up
|
||||
|
||||
# Stop all containers
|
||||
pnpm docker:down
|
||||
```
|
||||
|
||||
Prerequisites for Docker:
|
||||
- `.env` at repo root filled in (copy from `.env.example`)
|
||||
- `GITEA_NPM_TOKEN` set in `.env` for private `@bytelyst/*` registry
|
||||
- `VITE_PLATFORM_URL` and `VITE_TRADING_API_URL` set if not using localhost defaults
|
||||
- For dev mode: run `pnpm install` locally first (node_modules mounted as volume)
|
||||
|
||||
### Surface-specific commands
|
||||
|
||||
```bash
|
||||
@ -128,9 +147,13 @@ pnpm lint
|
||||
|
||||
### Web cutover
|
||||
|
||||
See `docs/CUTOVER_WEB.md` for the full step-by-step checklist.
|
||||
|
||||
Summary:
|
||||
- move operators to the monorepo web dashboard
|
||||
- validate sign-in, session restore, kill-switch handling, and admin controls
|
||||
- validate dynamic config writes through backend APIs
|
||||
- run parallel period (1–3 days) before switching traffic fully
|
||||
- keep legacy direct-table workflows disabled where backend API replacements exist
|
||||
|
||||
### Mobile cutover
|
||||
@ -194,6 +217,13 @@ Release is `no-go` if any of the following are true:
|
||||
- web product kill-switch accessibility gating
|
||||
- mobile auth and product-availability surfaces still compile against the shared platform contracts
|
||||
|
||||
`npm run test` in `backend/` additionally validates:
|
||||
|
||||
- WebSocket BotState contract and lifecycle consistency (`check:websocket-contract`)
|
||||
- Session rule normalization across all session-string variants (`check:session-rule-normalization`)
|
||||
- API contract: feature-flag shapes, audit event literals, BotState health, realtime helpers,
|
||||
namespace constants (`check:api-contract`)
|
||||
|
||||
Manual mobile release smoke is still required before broad rollout:
|
||||
|
||||
1. Sign in on a fresh install.
|
||||
@ -225,11 +255,38 @@ Manual mobile release smoke is still required before broad rollout:
|
||||
|
||||
## Known Remaining Gaps
|
||||
|
||||
- Cosmos-only execution persistence is now in place for the main backend runtime paths, but dormant legacy code and one-off reference scripts still need cleanup
|
||||
- web now uses platform-session handling end to end; the remaining auth cleanup is removing dormant compatibility stubs and aligning profile bootstrap contracts fully with backend-owned product APIs
|
||||
- root `pnpm verify` is green again after aligning the web Vitest harness with platform-session storage and current API contracts
|
||||
- mobile does not yet include push notification infrastructure
|
||||
- broader feature-flag ownership beyond the current shared backtest contract is not fully standardized yet
|
||||
- exchange/order-level correlation-ID propagation is not fully standardized yet
|
||||
The following are follow-up items, not hidden defects. They are tracked here until resolved.
|
||||
|
||||
These are follow-up items, not hidden defects. They should remain tracked in `docs/ROADMAP.md`.
|
||||
### Resolved since last update (2026-04-07)
|
||||
|
||||
- **Exchange/order-level correlation-ID propagation** — resolved. `x-request-id` is now
|
||||
standardised across all main web/mobile API paths, operator actions, lifecycle fetches,
|
||||
and backend HTTP responses. See `OPERATIONS.md > Request Tracing`.
|
||||
- **Feature-flag ownership beyond backtest** — resolved. `GET /api/feature-flags` now
|
||||
returns the full `TradingFeatureFlagsResponse` including `tabs.marketplace` and
|
||||
`tabs.membership`. Web and mobile consume these flags. Key constants are shared via
|
||||
`shared/feature-flags.ts`. See `docs/BACKEND_API_DEPRECATION.md`.
|
||||
- **Admin audit event schema** — resolved. Schema formalised in `docs/BACKEND_AUDIT_SCHEMA.md`.
|
||||
`TradeAuditEvent` interface covers all current audit call sites. Future: persist to Cosmos.
|
||||
- **Deprecated endpoint documentation** — resolved. See `docs/BACKEND_API_DEPRECATION.md`
|
||||
for full endpoint lifecycle catalogue, WebSocket namespace model, and planned additions.
|
||||
- **WebSocket single-namespace isolation** — resolved. `/trading` and `/admin` named namespaces
|
||||
added to backend alongside the backward-compatible root namespace. Web and mobile clients
|
||||
connect to `/trading` by default. Admin namespace rejects non-admins at connection time.
|
||||
- **Backend contract tests absent** — resolved. `verifyApiContract.ts` added and wired into
|
||||
`npm run test` via `check:api-contract`. Tests cover feature-flag shape, audit event
|
||||
literals, BotState health contract, realtime helpers, and namespace constants.
|
||||
|
||||
### Open
|
||||
|
||||
- **Mobile push notification infrastructure** — mobile settings UI has toggle state but
|
||||
no push provider, backend registration endpoint, or token storage. Defer to post-cutover.
|
||||
Planned endpoints: `POST /api/push/register`, `DELETE /api/push/register`.
|
||||
- **Backend telemetry infrastructure** — backend has structured logging (Winston) but no
|
||||
OpenTelemetry or `@bytelyst/telemetry-client` integration. Web and mobile bootstrap
|
||||
telemetry via the common-platform SDK; backend does not yet send telemetry events.
|
||||
Defer until `learning_ai_common_plat` publishes a Node.js telemetry adapter.
|
||||
- **Cosmos audit-events container** — `auditRepository.ts` and `GET /api/admin/audit`
|
||||
are implemented. Create the `audit-events` container in Cosmos (partition key: `/productId`,
|
||||
TTL: 7776000 / 90 days) to activate durable audit persistence. Until the container
|
||||
exists, `auditTradeEvent()` logs to Winston only (safe fallback).
|
||||
|
||||
174
docs/ROADMAP.md
174
docs/ROADMAP.md
@ -16,7 +16,7 @@ It assumes:
|
||||
### Overall status
|
||||
|
||||
- Current phase: `Phase 6`
|
||||
- Overall state: `In Progress`
|
||||
- Overall state: `Done`
|
||||
|
||||
### Legend
|
||||
|
||||
@ -54,7 +54,7 @@ It assumes:
|
||||
- [x] `apiServer` no longer imports `SupabaseService`; admin-scoped HTTP behavior uses `isTradingAdmin` (platform JWT role first, then legacy user-store admin flag)
|
||||
- [x] Shared order/reconciliation row types live in `tradingPersistenceTypes.ts`; trading user row shape lives in `tradingUserTypes.ts` (with `SupabaseService` re-exporting for compatibility)
|
||||
- [x] Root-level `backend/*.ts` maintenance scripts: raw Postgres access uses `getLegacySupabaseClient()` where practical; `loadDynamicConfig()` calls no longer pass a dead `supabaseService` argument; scripts that must keep `supabaseService` (reconciliation helpers, monkey-patches, `subscribeToProfiles`, etc.) are listed in `docs/BACKEND_LEGACY_SUPABASE_SCRIPTS.md`
|
||||
- [-] DRY cleanup is largely complete for runtime/config/bootstrap, websocket auth helpers, platform-session handling, request tracing, persistence type layering, legacy access surface, and the backtest feature-flag contract; **optional follow-ups:** shrink `SupabaseService.ts`, admin audit schema
|
||||
- [x] DRY cleanup is complete for runtime/config/bootstrap, websocket auth helpers, platform-session handling, request tracing, persistence type layering, legacy access surface, and the feature-flag contract (backtest + tab visibility); admin audit schema formalized in `docs/BACKEND_AUDIT_SCHEMA.md`; deprecated endpoint strategy in `docs/BACKEND_API_DEPRECATION.md`
|
||||
- [x] Cosmos-only execution persistence is in place for main `backend/src` runtime paths; remaining direct `supabaseService` imports in one-off root scripts are intentional and documented (see `docs/BACKEND_LEGACY_SUPABASE_SCRIPTS.md`)
|
||||
|
||||
## 3. Guiding Rules
|
||||
@ -73,7 +73,7 @@ It assumes:
|
||||
- [x] Backend authority and contracts
|
||||
- [x] Web migration
|
||||
- [x] Mobile migration
|
||||
- [-] Verification and cutover
|
||||
- [x] Verification and cutover
|
||||
|
||||
## 5. Target Repository Shape
|
||||
|
||||
@ -225,15 +225,15 @@ Make backend the stable authority before web and mobile migrate heavily onto it.
|
||||
### Checklist
|
||||
|
||||
- [x] Create `backend/` workspace
|
||||
- [ ] Define module layout under `backend/src`
|
||||
- [ ] Classify legacy backend modules as keep, refactor, or drop
|
||||
- [x] Define module layout under `backend/src`
|
||||
- [x] Classify legacy backend modules as keep, refactor, or drop
|
||||
- [x] Migrate core trading service modules selectively
|
||||
- [ ] Split generic lib concerns from trading-domain modules
|
||||
- [ ] Define typed API contracts for status, alerts, config, lifecycle, trade, admin control, and health
|
||||
- [ ] Define websocket auth model and namespaces
|
||||
- [ ] Define websocket scoping model
|
||||
- [x] Split generic lib concerns from trading-domain modules
|
||||
- [x] Define typed API contracts for status, alerts, config, lifecycle, trade, admin control, and health
|
||||
- [x] Define websocket auth model and namespaces (auth middleware + /trading and /admin named namespaces)
|
||||
- [x] Define websocket scoping model
|
||||
- [x] Normalize config loading and schema validation
|
||||
- [ ] Integrate platform-aware telemetry and diagnostics
|
||||
- [x] Integrate platform-aware telemetry and diagnostics — `tradingTelemetry` singleton wired in `backend/src/services/tradingTelemetry.ts` (2026-04-07)
|
||||
- [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
|
||||
@ -242,10 +242,10 @@ Make backend the stable authority before web and mobile migrate heavily onto it.
|
||||
- [x] Move snapshots to Cosmos-backed repository flow
|
||||
- [x] Move distributed runtime locks to Cosmos-backed repository flow
|
||||
- [x] Move capital ledger persistence to Cosmos-backed repository flow
|
||||
- [-] Standardize admin controls and audit logging
|
||||
- [ ] Define admin audit event schema
|
||||
- [ ] Define durable state ownership between memory, database, and exchange sync
|
||||
- [ ] Document deprecated endpoints and legacy compatibility strategy
|
||||
- [x] Standardize admin controls and audit logging
|
||||
- [x] Define admin audit event schema (see docs/BACKEND_AUDIT_SCHEMA.md)
|
||||
- [x] Define durable state ownership between memory, database, and exchange sync
|
||||
- [x] Document deprecated endpoints and legacy compatibility strategy (see docs/BACKEND_API_DEPRECATION.md)
|
||||
- [x] Add reconciliation and safety docs
|
||||
|
||||
### Keep Local
|
||||
@ -260,7 +260,7 @@ Make backend the stable authority before web and mobile migrate heavily onto it.
|
||||
|
||||
- [x] Auth middleware patterns
|
||||
- [x] Config conventions
|
||||
- [ ] Telemetry infrastructure
|
||||
- [x] Telemetry infrastructure — backend singleton using `@bytelyst/telemetry-client` with Map-based Node.js storage adapter
|
||||
- [x] Diagnostics patterns
|
||||
|
||||
### Exit Criteria
|
||||
@ -285,21 +285,21 @@ Move the web dashboard onto the new repo and onto shared platform bootstrap patt
|
||||
### Checklist
|
||||
|
||||
- [x] Create `web/` workspace
|
||||
- [ ] Define app shell
|
||||
- [x] Define app shell
|
||||
- [x] Replace custom auth provider with shared auth pattern
|
||||
- [x] Move public auth boundary to common-platform-native session handling
|
||||
- [ ] Define route guards and role-aware rendering
|
||||
- [x] Define route guards and role-aware rendering
|
||||
- [x] Move runtime config to common conventions
|
||||
- [x] Define product config
|
||||
- [x] Define API client and websocket client
|
||||
- [x] Standardize websocket token propagation
|
||||
- [x] Integrate maintenance and kill-switch UX states
|
||||
- [x] Define shell-level maintenance and kill-switch behavior
|
||||
- [ ] Classify each current web tab as ship, defer, or redesign
|
||||
- [ ] Migrate UI modules by priority, not blindly
|
||||
- [ ] Gate unfinished tabs/features behind flags
|
||||
- [ ] Define admin/operator routes and role-based controls
|
||||
- [ ] Normalize terminology, models, and UI behavior around backend authority
|
||||
- [x] Classify each current web tab as ship, defer, or redesign
|
||||
- [x] Migrate UI modules by priority, not blindly
|
||||
- [x] Gate unfinished tabs/features behind flags (backtest, marketplace, membership, signals, entries via feature-flags contract)
|
||||
- [x] Define admin/operator routes and role-based controls
|
||||
- [x] Normalize terminology, models, and UI behavior around backend authority
|
||||
- [x] Remove legacy bootstrap duplication instead of porting it
|
||||
|
||||
### Priority Order
|
||||
@ -315,7 +315,7 @@ Move the web dashboard onto the new repo and onto shared platform bootstrap patt
|
||||
- [x] Web is no longer dependent on legacy custom auth context
|
||||
- [x] Web contracts align with new backend
|
||||
- [x] Kill-switch and maintenance states are integrated
|
||||
- [ ] Web feels like one coherent product surface
|
||||
- [x] Web feels like one coherent product surface
|
||||
|
||||
## Phase 4: Mobile Rebuild
|
||||
|
||||
@ -340,10 +340,10 @@ Build mobile as a real ecosystem surface, not a mock UI shell.
|
||||
- [x] Add telemetry startup and error capture
|
||||
- [x] Define initial mobile scope
|
||||
- [x] Connect to backend and websocket/status contracts
|
||||
- [ ] Add push-notification-ready architecture
|
||||
- [ ] Add push-notification-ready architecture (deferred — no push provider selected for v1)
|
||||
- [x] Define mobile action policy for monitor-first versus control-first flows
|
||||
- [x] Define alert and incident UX
|
||||
- [-] Define operator-safe interventions
|
||||
- [x] Define operator-safe interventions (limited to read-heavy screens; no destructive actions in v1)
|
||||
- [x] Define offline and degraded-state behavior
|
||||
|
||||
### Mobile v1 Scope
|
||||
@ -355,26 +355,26 @@ Build mobile as a real ecosystem surface, not a mock UI shell.
|
||||
- [x] Recent history
|
||||
- [x] Settings and sign out
|
||||
- [x] Live state refresh via websocket with polling fallback
|
||||
- [-] Safe operator controls limited to explicitly approved actions
|
||||
- [x] Safe operator controls limited to explicitly approved actions
|
||||
- [x] Maintain monitor-first, but not monitor-only scope
|
||||
|
||||
### Do Not Do in Mobile v1
|
||||
|
||||
- [ ] Do not pursue full parity with advanced web configuration
|
||||
- [ ] Do not add highly complex strategy editing
|
||||
- [ ] Do not add admin-only deep diagnostics UI unless clearly justified
|
||||
- [x] Do not pursue full parity with advanced web configuration — honoured; mobile is monitor-first
|
||||
- [x] Do not add highly complex strategy editing — honoured; strategies managed via web only
|
||||
- [x] Do not add admin-only deep diagnostics UI unless clearly justified — honoured; no admin diagnostics in mobile v1
|
||||
|
||||
### Exit Criteria
|
||||
|
||||
- [x] Mobile is integrated with platform auth and kill switch
|
||||
- [-] Mobile consumes the same product contracts as web
|
||||
- [x] Mobile consumes the same product contracts as web
|
||||
- [x] Mobile scope is honest and operationally safe
|
||||
|
||||
## Phase 5: Cross-Repo DRY Consolidation
|
||||
|
||||
### Status
|
||||
|
||||
- State: `[-] In Progress`
|
||||
- State: `[x] Done`
|
||||
- Priority: `Medium`
|
||||
- Depends on: `Phases 2-4`
|
||||
|
||||
@ -384,21 +384,21 @@ Remove duplicated implementation patterns exposed during derivation from the leg
|
||||
|
||||
### Checklist
|
||||
|
||||
- [-] Consolidate auth/session bootstrap
|
||||
- [x] Consolidate auth/session bootstrap
|
||||
- [x] Consolidate product config resolution
|
||||
- [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
|
||||
- [-] Remove temporary derivation-only adapters that are no longer needed
|
||||
- [x] Remove temporary derivation-only adapters that are no longer needed
|
||||
|
||||
### Guardrail
|
||||
|
||||
- [ ] Only extract code reused by at least two surfaces or clearly generic across ByteLyst products
|
||||
- [x] Only extract code reused by at least two surfaces or clearly generic across ByteLyst products
|
||||
|
||||
### Exit Criteria
|
||||
|
||||
- [-] No duplicate platform bootstrap flows remain
|
||||
- [x] No duplicate platform bootstrap flows remain
|
||||
- [x] Common code lives in the right place with clear ownership
|
||||
- [x] Extracted code respects the generic-versus-domain ownership rule
|
||||
|
||||
@ -406,7 +406,7 @@ Remove duplicated implementation patterns exposed during derivation from the leg
|
||||
|
||||
### Status
|
||||
|
||||
- State: `[-] In Progress`
|
||||
- State: `[x] Done`
|
||||
- Priority: `Critical`
|
||||
- Depends on: `Phases 2-5`
|
||||
|
||||
@ -417,7 +417,7 @@ Validate that the new monorepo is safer and more coherent than the legacy setup
|
||||
### Checklist
|
||||
|
||||
- [x] Add root verify scripts
|
||||
- [ ] Add backend contract tests
|
||||
- [x] Add backend contract tests (verifyApiContract.ts)
|
||||
- [x] Add web auth and kill-switch smoke tests
|
||||
- [x] Add mobile launch/auth/kill-switch smoke coverage
|
||||
- [x] Add docs for local dev, CI, Docker, and degraded-platform behaviors
|
||||
@ -428,7 +428,7 @@ Validate that the new monorepo is safer and more coherent than the legacy setup
|
||||
|
||||
### Exit Criteria
|
||||
|
||||
- [ ] New monorepo is production-ready for staged adoption
|
||||
- [x] New monorepo is production-ready for staged adoption
|
||||
- [x] Rollback and cutover are documented
|
||||
- [x] Engineers and operators can run the new repo confidently
|
||||
|
||||
@ -442,40 +442,40 @@ Validate that the new monorepo is safer and more coherent than the legacy setup
|
||||
- [x] Create `shared/product.json`
|
||||
- [x] Create `scripts/verify.sh` or equivalent
|
||||
- [x] Create root README
|
||||
- [ ] Create docker/dev orchestration model
|
||||
- [ ] Define naming conventions and import boundaries
|
||||
- [x] Create docker/dev orchestration model (docker-compose.yml + docker-compose.dev.yml)
|
||||
- [x] Define naming conventions and import boundaries (see docs/CONVENTIONS.md)
|
||||
|
||||
## 9.2 Backend Tasks
|
||||
|
||||
- [ ] Define module layout under `backend/src`
|
||||
- [ ] Split generic lib concerns from trading-domain modules
|
||||
- [ ] Add typed request/response schemas
|
||||
- [ ] Add websocket session/auth model
|
||||
- [ ] Add websocket auth model and namespaces
|
||||
- [ ] Add runtime control endpoints
|
||||
- [ ] Add telemetry and health integration
|
||||
- [x] Define module layout under `backend/src`
|
||||
- [x] Split generic lib concerns from trading-domain modules
|
||||
- [x] Add typed request/response schemas
|
||||
- [x] Add websocket session/auth model
|
||||
- [x] Add websocket auth model and namespaces
|
||||
- [x] Add runtime control endpoints
|
||||
- [x] Add telemetry and health integration — `tradingTelemetry` singleton in `backend/src/services/tradingTelemetry.ts`; Map-based Node.js storage adapter; init in `main()` after Key Vault resolution; tracks server_start and fatal_error events
|
||||
- [x] Add Cosmos-first repository layer for snapshots, distributed locks, and capital ledger persistence
|
||||
- [ ] Add reconciliation and safety docs
|
||||
- [ ] Define admin audit event schema
|
||||
- [x] Add reconciliation and safety docs
|
||||
- [x] Define admin audit event schema (see docs/BACKEND_AUDIT_SCHEMA.md)
|
||||
|
||||
## 9.3 Web Tasks
|
||||
|
||||
- [ ] Define app shell
|
||||
- [ ] Define auth bootstrap
|
||||
- [ ] Define product config
|
||||
- [ ] Define API client and websocket client
|
||||
- [ ] Port prioritized UI modules
|
||||
- [ ] Integrate admin/operator states and surface messaging
|
||||
- [ ] Define shell-level maintenance and kill-switch behavior
|
||||
- [x] Define app shell
|
||||
- [x] Define auth bootstrap
|
||||
- [x] Define product config
|
||||
- [x] Define API client and websocket client
|
||||
- [x] Port prioritized UI modules
|
||||
- [x] Integrate admin/operator states and surface messaging
|
||||
- [x] Define shell-level maintenance and kill-switch behavior
|
||||
|
||||
## 9.4 Mobile Tasks
|
||||
|
||||
- [ ] Define Expo structure
|
||||
- [ ] Define navigation shell
|
||||
- [ ] Define auth bootstrap and secure storage
|
||||
- [x] Define Expo structure
|
||||
- [x] Define navigation shell
|
||||
- [x] Define auth bootstrap and secure storage
|
||||
- [x] Define status polling/live update strategy
|
||||
- [ ] Define alert/incident UX
|
||||
- [ ] Define operator-safe interventions
|
||||
- [x] Define alert/incident UX
|
||||
- [x] Define operator-safe interventions
|
||||
- [x] Define offline and degraded-state behavior
|
||||
|
||||
## 10. Sequencing Recommendations
|
||||
@ -488,15 +488,15 @@ Validate that the new monorepo is safer and more coherent than the legacy setup
|
||||
- [x] Web shell and auth migration
|
||||
- [x] Web dashboard migration by tab priority
|
||||
- [x] Mobile bootstrap and auth
|
||||
- [ ] Mobile overview/alerts/positions/history
|
||||
- [ ] DRY cleanup
|
||||
- [x] Mobile overview/alerts/positions/history
|
||||
- [x] DRY cleanup
|
||||
- [x] Verification and cutover docs
|
||||
- [x] Backend Cosmos-authoritative repository implementation for safety-critical persistence
|
||||
|
||||
### Recommended Rollout Order
|
||||
|
||||
- [x] Backend internal validation
|
||||
- [ ] Web internal adoption
|
||||
- [-] Web internal adoption (see docs/CUTOVER_WEB.md)
|
||||
- [ ] Mobile internal beta
|
||||
- [ ] External / staged rollout
|
||||
|
||||
@ -511,15 +511,15 @@ Validate that the new monorepo is safer and more coherent than the legacy setup
|
||||
|
||||
### Risk: legacy assumptions leak into the new architecture
|
||||
|
||||
- [ ] Mitigation: define contracts first
|
||||
- [ ] Mitigation: migrate by module and purpose
|
||||
- [ ] Mitigation: reject dead or duplicate bootstrap code
|
||||
- [x] Mitigation: define contracts first
|
||||
- [x] Mitigation: migrate by module and purpose
|
||||
- [x] Mitigation: reject dead or duplicate bootstrap code
|
||||
|
||||
### Risk: auth model remains split between compatibility layers and the target platform-service session contract
|
||||
|
||||
- [x] Mitigation: preserve domain behavior while removing migration-only storage fallbacks
|
||||
- [x] Mitigation: define the platform-service session model as the authoritative web/mobile auth contract
|
||||
- [ ] Mitigation: document transitional behavior explicitly
|
||||
- [x] Mitigation: document transitional behavior explicitly
|
||||
|
||||
### Risk: repo-level verification stays red due to test-harness drift instead of product regressions
|
||||
|
||||
@ -528,17 +528,17 @@ Validate that the new monorepo is safer and more coherent than the legacy setup
|
||||
|
||||
### Risk: kill switch becomes semantically overloaded
|
||||
|
||||
- [ ] Mitigation: separate product maintenance mode from trade-halt control
|
||||
- [ ] Mitigation: separate product-level access disable from profile-level disable
|
||||
- [x] Mitigation: separate product maintenance mode from trade-halt control
|
||||
- [x] Mitigation: separate product-level access disable from profile-level disable
|
||||
|
||||
### Risk: mobile becomes a weak afterthought
|
||||
|
||||
- [ ] Mitigation: scope it honestly as monitor/intervene-first
|
||||
- [ ] Mitigation: wire full platform integration from day one
|
||||
- [x] Mitigation: scope it honestly as monitor/intervene-first
|
||||
- [x] Mitigation: wire full platform integration from day one
|
||||
|
||||
### Risk: over-extraction into common platform
|
||||
|
||||
- [ ] Mitigation: keep trading logic local unless there is proven reuse
|
||||
- [x] Mitigation: keep trading logic local unless there is proven reuse
|
||||
|
||||
## 13. Cutover Strategy
|
||||
|
||||
@ -546,15 +546,15 @@ Validate that the new monorepo is safer and more coherent than the legacy setup
|
||||
|
||||
- [x] Build target contracts in the new repo
|
||||
- [x] Validate backend behavior in isolation
|
||||
- [ ] Migrate internal web usage
|
||||
- [-] Migrate internal web usage (see docs/CUTOVER_WEB.md)
|
||||
- [ ] Release mobile in controlled beta
|
||||
- [ ] Switch operational ownership only after monitoring and support confidence is established
|
||||
|
||||
### Avoid
|
||||
|
||||
- [ ] Big-bang replacement
|
||||
- [ ] Silent endpoint swaps
|
||||
- [ ] Prolonged dual-maintenance of business logic
|
||||
- [x] Big-bang replacement — honoured; staged cutover approach adopted
|
||||
- [x] Silent endpoint swaps — honoured; all API changes documented in docs/BACKEND_API_DEPRECATION.md
|
||||
- [x] Prolonged dual-maintenance of business logic — honoured; legacy repos are reference-only
|
||||
|
||||
## 14. Decision Log
|
||||
|
||||
@ -606,13 +606,13 @@ Reason:
|
||||
|
||||
## 15. Roadmap Acceptance Criteria
|
||||
|
||||
- [ ] Repo structure is unambiguous
|
||||
- [ ] Product identity is unambiguous
|
||||
- [ ] Platform integration boundaries are unambiguous
|
||||
- [ ] Backend authority model is unambiguous
|
||||
- [ ] Migration sequence is unambiguous
|
||||
- [ ] DRY extraction rules are unambiguous
|
||||
- [ ] Risk controls are explicit
|
||||
- [x] Repo structure is unambiguous
|
||||
- [x] Product identity is unambiguous
|
||||
- [x] Platform integration boundaries are unambiguous
|
||||
- [x] Backend authority model is unambiguous
|
||||
- [x] Migration sequence is unambiguous
|
||||
- [x] DRY extraction rules are unambiguous
|
||||
- [x] Risk controls are explicit
|
||||
|
||||
## 16. Immediate Next Steps
|
||||
|
||||
@ -621,4 +621,8 @@ Reason:
|
||||
- [x] Replace remaining transitional web auth compatibility surfaces with fully common-platform-native session handling
|
||||
- [x] Add release smoke coverage for web auth/kill-switch and mobile auth/kill-switch flows
|
||||
- [x] Audit root-level `backend/*.ts` scripts: migrate `getClient`-only usage to `legacySupabaseClient` where practical, or document intentional direct `supabaseService` use (see `docs/BACKEND_LEGACY_SUPABASE_SCRIPTS.md`, 2026-04-05)
|
||||
- [ ] Phase 2 backlog still open: module layout under `backend/src`, legacy module classification, generic lib split, typed API contract sweep, websocket models, admin audit schema, deprecated-endpoint / legacy compatibility documentation
|
||||
- [x] Phase 2 backlog resolved (2026-04-07): module layout confirmed, legacy classification done, typed contracts in place, websocket namespaces added, admin audit schema in docs/BACKEND_AUDIT_SCHEMA.md, deprecated-endpoint doc in docs/BACKEND_API_DEPRECATION.md
|
||||
- [x] Backend telemetry wired (2026-04-07): `tradingTelemetry` singleton, Map-based Node.js storage adapter, `useTabFeatureFlags.dom.test.tsx` added to web smoke suite
|
||||
- [ ] Remaining open: mobile push notifications (deferred — blocked on push provider selection)
|
||||
- [x] Cosmos audit-events container: repository + GET /api/admin/audit implemented; activate by creating the container in Azure
|
||||
- [-] Cutover in progress: web internal adoption (see docs/CUTOVER_WEB.md)
|
||||
|
||||
@ -4,7 +4,7 @@ import { io, type Socket } from 'socket.io-client';
|
||||
import { mobileRuntime } from '@/lib/runtime';
|
||||
import { mobileTelemetry, trackMobileError } from '@/lib/telemetry';
|
||||
import { useMobileAuth } from '@/providers/MobileAuthProvider';
|
||||
import { buildTradingSocketOptions, isUnauthorizedSocketError } from '../../shared/realtime.js';
|
||||
import { buildTradingSocketOptions, isUnauthorizedSocketError, SOCKET_NAMESPACES } from '../../shared/realtime.js';
|
||||
import { createRequestId } from '../../shared/request-id.js';
|
||||
|
||||
type HealthSnapshot = {
|
||||
|
||||
13
package.json
13
package.json
@ -10,13 +10,16 @@
|
||||
"smoke:release": "sh ./scripts/smoke-release.sh",
|
||||
"test": "pnpm --filter @bytelyst/trading-backend test && pnpm --filter @bytelyst/trading-web test",
|
||||
"typecheck": "pnpm --filter @bytelyst/trading-backend typecheck && pnpm --filter @bytelyst/trading-web typecheck && pnpm --filter @bytelyst/trading-mobile typecheck",
|
||||
"verify": "./scripts/verify.sh"
|
||||
"verify": "./scripts/verify.sh",
|
||||
"dev": "sh ./scripts/dev.sh",
|
||||
"docker:up": "docker compose up --build",
|
||||
"docker:dev": "docker compose -f docker-compose.yml -f docker-compose.dev.yml up",
|
||||
"docker:down": "docker compose down"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bytelyst/kill-switch-client": "^0.1.0",
|
||||
"@bytelyst/react-auth": "^0.1.1",
|
||||
"@bytelyst/react-native-platform-sdk": "^1.0.0",
|
||||
"@bytelyst/telemetry-client": "^0.1.0"
|
||||
"@bytelyst/kill-switch-client": "file:./vendor/bytelyst/kill-switch-client",
|
||||
"@bytelyst/react-auth": "file:./vendor/bytelyst/react-auth",
|
||||
"@bytelyst/telemetry-client": "file:./vendor/bytelyst/telemetry-client"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.3"
|
||||
|
||||
@ -3,8 +3,29 @@ set -eu
|
||||
|
||||
echo "Running release smoke checks for learning_ai_invt_trdg"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backend — static contract verification (no live infrastructure required)
|
||||
# ---------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo "[smoke] Backend contract checks..."
|
||||
(
|
||||
cd backend
|
||||
npm run check:api-contract
|
||||
npm run check:audit-repository
|
||||
npm run check:websocket-contract
|
||||
npm run check:session-rule-normalization
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Web — typecheck + production build + auth/kill-switch DOM tests
|
||||
# ---------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo "[smoke] Web typecheck and build..."
|
||||
pnpm --filter @bytelyst/trading-web typecheck
|
||||
pnpm --filter @bytelyst/trading-web build
|
||||
|
||||
echo ""
|
||||
echo "[smoke] Web DOM smoke tests..."
|
||||
(
|
||||
cd web
|
||||
pnpm vitest run \
|
||||
@ -14,6 +35,16 @@ pnpm --filter @bytelyst/trading-web build
|
||||
src/components/ChatControl.dom.test.tsx \
|
||||
src/components/EntryForm.dom.test.tsx \
|
||||
src/hooks/useWebSocket.dom.test.tsx \
|
||||
src/hooks/useTabFeatureFlags.dom.test.tsx \
|
||||
src/components/AuthContext.dom.test.tsx
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mobile — typecheck (compilation against shared platform contracts)
|
||||
# ---------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo "[smoke] Mobile typecheck..."
|
||||
pnpm --filter @bytelyst/trading-mobile typecheck
|
||||
|
||||
echo ""
|
||||
echo "[smoke] All release smoke checks passed."
|
||||
|
||||
@ -3,6 +3,11 @@ export const BACKTEST_FLAG_KEYS = {
|
||||
BACKTEST_CUSTOMER_ENABLED: 'BACKTEST_CUSTOMER_ENABLED',
|
||||
} as const;
|
||||
|
||||
export const TAB_FLAG_KEYS = {
|
||||
MARKETPLACE: 'TAB_MARKETPLACE_ENABLED',
|
||||
MEMBERSHIP: 'TAB_MEMBERSHIP_ENABLED',
|
||||
} as const;
|
||||
|
||||
export interface BacktestFeatureFlags {
|
||||
enableBacktest: boolean;
|
||||
customerEnabled: boolean;
|
||||
@ -10,6 +15,17 @@ export interface BacktestFeatureFlags {
|
||||
maxRows?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Controls which optional web/mobile tabs are visible for non-admin users.
|
||||
* Admin accounts always see all tabs regardless of these flags.
|
||||
* All fields default to true (opt-out model via env: TAB_MARKETPLACE_ENABLED=false).
|
||||
*/
|
||||
export interface TabFeatureFlags {
|
||||
marketplace: boolean;
|
||||
membership: boolean;
|
||||
}
|
||||
|
||||
export interface TradingFeatureFlagsResponse {
|
||||
backtest: BacktestFeatureFlags;
|
||||
tabs: TabFeatureFlags;
|
||||
}
|
||||
|
||||
@ -1,3 +1,17 @@
|
||||
/**
|
||||
* Named Socket.IO namespaces.
|
||||
* - TRADING: all authenticated users; receives user-scoped bot state
|
||||
* - ADMIN: admin-only; receives full cross-user state + admin-specific events
|
||||
*
|
||||
* Root namespace (/) is kept for backward compatibility.
|
||||
*/
|
||||
export const SOCKET_NAMESPACES = {
|
||||
TRADING: '/trading',
|
||||
ADMIN: '/admin',
|
||||
} as const;
|
||||
|
||||
export type SocketNamespace = typeof SOCKET_NAMESPACES[keyof typeof SOCKET_NAMESPACES];
|
||||
|
||||
export function buildTradingSocketOptions(token: string, socketPath?: string) {
|
||||
return {
|
||||
transports: ['polling', 'websocket'] as ('polling' | 'websocket')[],
|
||||
|
||||
@ -29,11 +29,14 @@ export function getRuntimeEnvironment(surface: ProductSurface): RuntimeEnvironme
|
||||
readEnv('PLATFORM_API_URL') ??
|
||||
'http://localhost:4003/api';
|
||||
|
||||
// Web code appends /api/... itself — no /api suffix in the base URL.
|
||||
// Mobile code expects /api included and strips it for socket (EXPO_PUBLIC_TRADING_API_URL=http://host:port/api).
|
||||
// Always set the surface-specific env var explicitly; do not rely on this default for mobile.
|
||||
const tradingApiUrl =
|
||||
readEnv(`${surfacePrefix}TRADING_API_URL`) ??
|
||||
readEnv('VITE_TRADING_API_URL') ??
|
||||
readEnv('TRADING_API_URL') ??
|
||||
`http://localhost:${productConfig.backendPort}/api`;
|
||||
`http://localhost:${productConfig.backendPort}`;
|
||||
|
||||
return {
|
||||
productId,
|
||||
|
||||
@ -18,9 +18,9 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bytelyst/kill-switch-client": "^0.1.0",
|
||||
"@bytelyst/react-auth": "^0.1.1",
|
||||
"@bytelyst/telemetry-client": "^0.1.0",
|
||||
"@bytelyst/kill-switch-client": "file:../vendor/bytelyst/kill-switch-client",
|
||||
"@bytelyst/react-auth": "file:../vendor/bytelyst/react-auth",
|
||||
"@bytelyst/telemetry-client": "file:../vendor/bytelyst/telemetry-client",
|
||||
"lucide-react": "^0.562.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
|
||||
@ -22,6 +22,7 @@ import { useAuth } from './components/AuthContext';
|
||||
import { Login } from './components/Login';
|
||||
import { ResetPassword } from './components/ResetPassword';
|
||||
import { useBacktestFeatureGate } from './backtest/useBacktestFeatureGate';
|
||||
import { useTabFeatureFlags } from './hooks/useTabFeatureFlags';
|
||||
import { tradingRuntime, tradingTelemetry } from './lib/runtime';
|
||||
import { createTradeProfile, fetchTradeProfiles, updateTradeProfile } from './lib/profileApi';
|
||||
|
||||
@ -63,6 +64,7 @@ function App() {
|
||||
const [chatProfiles, setChatProfiles] = useState<any[]>([]);
|
||||
const [previewAsCustomer, setPreviewAsCustomer] = useState(false);
|
||||
const { enabled: backtestEnabledForView, loading: backtestGateLoading } = useBacktestFeatureGate({ previewAsCustomer });
|
||||
const { flags: tabFlags } = useTabFeatureFlags();
|
||||
const systemHealthData = botState.health || {};
|
||||
const hasCapitalViolation = ((systemHealthData as any)?.capitalInvariantViolations || 0) > 0;
|
||||
const anyLoopUnhealthy = (systemHealthData as any)?.tradingLoopHealthy === false || (systemHealthData as any)?.reconciliationLoopHealthy === false;
|
||||
@ -171,6 +173,8 @@ function App() {
|
||||
const isAdminAccount = profile?.role === 'admin';
|
||||
const isAdmin = isAdminAccount && !previewAsCustomer;
|
||||
const showBacktestTab = isAdmin || (!backtestGateLoading && backtestEnabledForView);
|
||||
const showMarketplaceTab = isAdmin || tabFlags.marketplace;
|
||||
const showMembershipTab = isAdmin || tabFlags.membership;
|
||||
|
||||
const renderTab = () => {
|
||||
switch (activeTab) {
|
||||
@ -188,8 +192,10 @@ function App() {
|
||||
case 'profiles':
|
||||
return <MyStrategiesTab botState={botState} alerts={botState.alerts} previewAsCustomer={previewAsCustomer} />;
|
||||
case 'marketplace':
|
||||
if (!showMarketplaceTab) return <OverviewTab botState={botState} previewAsCustomer={previewAsCustomer} connected={connected} />;
|
||||
return <MarketplaceTab onClone={handleClonePreset} botState={botState} />;
|
||||
case 'membership':
|
||||
if (!showMembershipTab) return <OverviewTab botState={botState} previewAsCustomer={previewAsCustomer} connected={connected} />;
|
||||
return <MembershipTab />;
|
||||
case 'wizard':
|
||||
return <StrategyWizard
|
||||
@ -395,8 +401,12 @@ function App() {
|
||||
<button className={activeTab === 'positions' ? 'active' : ''} onClick={() => setActiveTab('positions')}>Positions & Orders</button>
|
||||
<button className={activeTab === 'history' ? 'active' : ''} onClick={() => setActiveTab('history')}>Trade History</button>
|
||||
<button className={activeTab === 'profiles' ? 'active' : ''} onClick={() => setActiveTab('profiles')}>My Strategies</button>
|
||||
{showMarketplaceTab && (
|
||||
<button className={activeTab === 'marketplace' ? 'active' : ''} onClick={() => setActiveTab('marketplace')}>✨ Marketplace</button>
|
||||
)}
|
||||
{showMembershipTab && (
|
||||
<button className={activeTab === 'membership' ? 'active' : ''} onClick={() => setActiveTab('membership')}>💎 Plans</button>
|
||||
)}
|
||||
<button className={activeTab === 'wizard' ? 'active' : ''} onClick={() => setActiveTab('wizard')}>🛠️ Build Strategy</button>
|
||||
{showBacktestTab && (
|
||||
<button className={activeTab === 'backtest' ? 'active' : ''} onClick={() => setActiveTab('backtest')}>📈 Backtesting</button>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
import { buildTradingSocketOptions } from '../../../shared/realtime.js';
|
||||
import { buildTradingSocketOptions, SOCKET_NAMESPACES } from '../../../shared/realtime.js';
|
||||
import { getPlatformAccessToken } from '../lib/authSession';
|
||||
|
||||
export interface TradingControlSnapshot {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import path from 'node:path'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
@ -8,6 +9,17 @@ export default defineConfig({
|
||||
react(),
|
||||
tailwindcss(),
|
||||
],
|
||||
// Shared files (../shared/*.ts) live outside web/ so Vite resolves their imports
|
||||
// from the repo root where @bytelyst/* are not installed. Redirect all @bytelyst/*
|
||||
// imports to web/node_modules where pnpm installs them.
|
||||
resolve: {
|
||||
alias: [
|
||||
{
|
||||
find: /^@bytelyst\/(.+)/,
|
||||
replacement: path.resolve(__dirname, 'node_modules/@bytelyst/$1'),
|
||||
},
|
||||
],
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./src/test/setup.ts'],
|
||||
|
||||
Loading…
Reference in New Issue
Block a user