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_PUBLIC_KEY=
|
||||||
PLATFORM_JWT_JWKS_URL=
|
PLATFORM_JWT_JWKS_URL=
|
||||||
|
|
||||||
# Product backend endpoint
|
# Product backend endpoint (no /api suffix — used by backend-side runtime only)
|
||||||
TRADING_API_URL=http://localhost:4018/api
|
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 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/
|
AZURE_KEYVAULT_URL=https://kv-mywisprai.vault.azure.net/
|
||||||
@ -32,14 +32,16 @@ AZURE_OPENAI_KEY=
|
|||||||
AZURE_OPENAI_DEPLOYMENT=gpt-4o
|
AZURE_OPENAI_DEPLOYMENT=gpt-4o
|
||||||
|
|
||||||
# Web-specific public envs
|
# 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_PRODUCT_ID=invttrdg
|
||||||
NEXT_PUBLIC_PLATFORM_URL=http://localhost:4003/api
|
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_PRODUCT_ID=invttrdg
|
||||||
VITE_PLATFORM_URL=http://localhost:4003/api
|
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
|
# 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_PRODUCT_ID=invttrdg
|
||||||
EXPO_PUBLIC_PLATFORM_URL=http://localhost:4003/api
|
EXPO_PUBLIC_PLATFORM_URL=http://localhost:4003/api
|
||||||
EXPO_PUBLIC_TRADING_API_URL=http://localhost:4018/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
|
PORT=4018
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
CORS_ALLOWED_ORIGINS=http://localhost:3048,http://localhost:8081
|
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.
|
# Legacy data-plane fallback only. Backend auth prefers platform JWTs.
|
||||||
SUPABASE_URL=
|
SUPABASE_URL=
|
||||||
SUPABASE_ANON_KEY=
|
SUPABASE_ANON_KEY=
|
||||||
|
|||||||
2
.npmrc
2
.npmrc
@ -1,2 +1,4 @@
|
|||||||
@bytelyst:registry=https://gitea.bytelyst.com/api/packages/ByteLyst/npm/
|
@bytelyst:registry=https://gitea.bytelyst.com/api/packages/ByteLyst/npm/
|
||||||
//gitea.bytelyst.com/api/packages/ByteLyst/npm/:_authToken=${GITEA_NPM_TOKEN}
|
//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
|
# 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
|
## Workspaces
|
||||||
|
|
||||||
- `backend/` — trading backend and execution/runtime APIs
|
| Package | Path | Description |
|
||||||
- `web/` — trading dashboard
|
|---|---|---|
|
||||||
- `mobile/` — Expo mobile app
|
| `@bytelyst/trading-backend` | `backend/` | Node.js trading engine, REST API, Socket.IO |
|
||||||
- `shared/` — canonical product identity and shared runtime helpers
|
| `@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/*`
|
## Quick Start
|
||||||
|
|
||||||
## 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
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm verify
|
cp .env.example .env # root — used by Docker Compose and CI
|
||||||
pnpm lint
|
cp backend/.env.example backend/.env # backend — fill in Cosmos, exchange, and AI credentials
|
||||||
pnpm build
|
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`
|
```bash
|
||||||
- execution tracker: `docs/ROADMAP.md`
|
# Verification (run before every merge / deploy)
|
||||||
- local dev, cutover, rollback, and release checks: `docs/OPERATIONS.md`
|
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}
|
ENV GITEA_NPM_TOKEN=${GITEA_NPM_TOKEN}
|
||||||
|
|
||||||
# Copy workspace root files first (layer cache)
|
# 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 .npmrc pnpm-workspace.yaml pnpm-lock.yaml* ./
|
||||||
COPY package.json ./package.json
|
COPY package.json ./package.json
|
||||||
COPY backend/package.json ./backend/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
|
# Install backend deps only
|
||||||
RUN pnpm install --filter @bytelyst/trading-backend
|
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 .npmrc pnpm-workspace.yaml pnpm-lock.yaml* ./
|
||||||
COPY package.json ./package.json
|
COPY package.json ./package.json
|
||||||
COPY backend/package.json ./backend/package.json
|
COPY backend/package.json ./backend/package.json
|
||||||
|
COPY vendor/ ./vendor/
|
||||||
|
|
||||||
RUN pnpm install --filter @bytelyst/trading-backend --prod
|
RUN pnpm install --filter @bytelyst/trading-backend --prod
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
"description": "ByteLyst Trading backend and execution control service",
|
"description": "ByteLyst Trading backend and execution control service",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"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",
|
"dev": "node --import tsx src/bootstrap.ts",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
@ -27,6 +27,8 @@
|
|||||||
"check:reconciliation-exit-backfill-evidence-guard": "node --import tsx testReconciliationExitBackfillEvidenceGuard.ts",
|
"check:reconciliation-exit-backfill-evidence-guard": "node --import tsx testReconciliationExitBackfillEvidenceGuard.ts",
|
||||||
"check:backtest-isolation": "node --import tsx testBacktestIsolation.ts",
|
"check:backtest-isolation": "node --import tsx testBacktestIsolation.ts",
|
||||||
"check:session-rule-normalization": "node --import tsx testSessionRuleNormalization.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",
|
"check:websocket-contract": "node --import tsx src/scripts/verifyWebsocketContract.ts",
|
||||||
"coverage:run": "node --loader ts-node/esm runCoverageSuite.ts",
|
"coverage:run": "node --loader ts-node/esm runCoverageSuite.ts",
|
||||||
"coverage:full": "npm run coverage:integration",
|
"coverage:full": "npm run coverage:integration",
|
||||||
@ -53,10 +55,10 @@
|
|||||||
"@azure/cosmos": "^4.3.0",
|
"@azure/cosmos": "^4.3.0",
|
||||||
"@azure/identity": "^4.10.0",
|
"@azure/identity": "^4.10.0",
|
||||||
"@azure/keyvault-secrets": "^4.9.0",
|
"@azure/keyvault-secrets": "^4.9.0",
|
||||||
"@bytelyst/auth": "^0.1.0",
|
"@bytelyst/auth": "file:../vendor/bytelyst/auth",
|
||||||
"@bytelyst/config": "^0.1.0",
|
"@bytelyst/config": "file:../vendor/bytelyst/config",
|
||||||
"@bytelyst/cosmos": "^0.1.0",
|
"@bytelyst/cosmos": "file:../vendor/bytelyst/cosmos",
|
||||||
"@bytelyst/llm": "^0.1.1",
|
"@bytelyst/llm": "file:../vendor/bytelyst/llm",
|
||||||
"@alpacahq/alpaca-trade-api": "^3.1.3",
|
"@alpacahq/alpaca-trade-api": "^3.1.3",
|
||||||
"@supabase/supabase-js": "^2.90.1",
|
"@supabase/supabase-js": "^2.90.1",
|
||||||
"@types/cors": "^2.8.19",
|
"@types/cors": "^2.8.19",
|
||||||
|
|||||||
@ -28,4 +28,5 @@ await resolveSecrets(INVTTRDG_SECRETS, {
|
|||||||
|
|
||||||
// Dynamic import ensures config/index.ts (and all transitive modules) evaluate
|
// Dynamic import ensures config/index.ts (and all transitive modules) evaluate
|
||||||
// AFTER process.env is fully populated above.
|
// AFTER process.env is fully populated above.
|
||||||
|
// tradingTelemetry.init() is called at the start of main() in index.ts.
|
||||||
await import('./index.js');
|
await import('./index.js');
|
||||||
|
|||||||
@ -106,6 +106,9 @@ export const config = {
|
|||||||
BACKTEST_CUSTOMER_ENABLED: process.env.BACKTEST_CUSTOMER_ENABLED === 'true',
|
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_CSV_BYTES: parseInt(process.env.BACKTEST_MAX_CSV_BYTES || '5242880', 10), // 5MB
|
||||||
BACKTEST_MAX_ROWS: parseInt(process.env.BACKTEST_MAX_ROWS || '200000', 10),
|
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
|
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
|
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
|
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 { healthTracker } from './services/healthTracker.js';
|
||||||
import { observabilityService } from './services/observabilityService.js';
|
import { observabilityService } from './services/observabilityService.js';
|
||||||
|
import { tradingTelemetry } from './services/tradingTelemetry.js';
|
||||||
import { reconciliationService } from './services/reconciliationService.js';
|
import { reconciliationService } from './services/reconciliationService.js';
|
||||||
import { reconciliationWatchdogAutoResumeService } from './services/reconciliationWatchdogAutoResumeService.js';
|
import { reconciliationWatchdogAutoResumeService } from './services/reconciliationWatchdogAutoResumeService.js';
|
||||||
import { listActiveTradeProfiles } from './services/profileRepository.js';
|
import { listActiveTradeProfiles } from './services/profileRepository.js';
|
||||||
@ -24,6 +25,11 @@ import * as runtimeOrderRepository from './services/runtimeOrderRepository.js';
|
|||||||
async function main() {
|
async function main() {
|
||||||
logger.info(`Starting ${config.PRODUCT_ID} trading backend...`);
|
logger.info(`Starting ${config.PRODUCT_ID} trading backend...`);
|
||||||
validateConfig();
|
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) ---
|
// --- 0. Primary Account Setup (for Market Data) ---
|
||||||
await loadDynamicConfig();
|
await loadDynamicConfig();
|
||||||
@ -1075,5 +1081,9 @@ async function main() {
|
|||||||
|
|
||||||
main().catch(err => {
|
main().catch(err => {
|
||||||
logger.error('Critical Error:', err);
|
logger.error('Critical Error:', err);
|
||||||
|
tradingTelemetry.trackEvent('error', 'trading_loop', 'fatal_error', {
|
||||||
|
message: err?.message ?? String(err),
|
||||||
|
});
|
||||||
|
void tradingTelemetry.shutdown();
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -60,7 +60,8 @@ class SupabaseService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
constructor() {
|
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);
|
this.client = createClient(config.SUPABASE_URL, config.SUPABASE_KEY);
|
||||||
} else {
|
} else {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
|
|||||||
@ -48,6 +48,8 @@ import {
|
|||||||
type CanonicalLifecycleProfileMeta
|
type CanonicalLifecycleProfileMeta
|
||||||
} from './canonicalLifecycleService.js';
|
} from './canonicalLifecycleService.js';
|
||||||
import type { TradingFeatureFlagsResponse } from '../../../shared/feature-flags.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 {
|
interface AuthenticatedRequest extends Request {
|
||||||
authUserId?: string;
|
authUserId?: string;
|
||||||
@ -805,6 +807,8 @@ export class ApiServer {
|
|||||||
...evt
|
...evt
|
||||||
};
|
};
|
||||||
logger.info(`[AUDIT] ${JSON.stringify(payload)}`);
|
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 {
|
private buildLocalChatFallback(message: string, context: any[]): ChatResponsePayload {
|
||||||
@ -1652,7 +1656,11 @@ export class ApiServer {
|
|||||||
customerEnabled: Boolean(config.BACKTEST_CUSTOMER_ENABLED),
|
customerEnabled: Boolean(config.BACKTEST_CUSTOMER_ENABLED),
|
||||||
maxCsvBytes: Number(config.BACKTEST_MAX_CSV_BYTES),
|
maxCsvBytes: Number(config.BACKTEST_MAX_CSV_BYTES),
|
||||||
maxRows: Number(config.BACKTEST_MAX_ROWS),
|
maxRows: Number(config.BACKTEST_MAX_ROWS),
|
||||||
}
|
},
|
||||||
|
tabs: {
|
||||||
|
marketplace: Boolean(config.TAB_MARKETPLACE_ENABLED),
|
||||||
|
membership: Boolean(config.TAB_MEMBERSHIP_ENABLED),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
res.json(flags);
|
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 ---
|
// --- NEW: Clear Operational Events ---
|
||||||
this.app.delete('/api/events', this.requireAuth, this.requireAdmin, async (req, res) => {
|
this.app.delete('/api/events', this.requireAuth, this.requireAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@ -2598,34 +2620,50 @@ RULES:
|
|||||||
}
|
}
|
||||||
|
|
||||||
private setupSocketHandlers() {
|
private setupSocketHandlers() {
|
||||||
this.io.use(async (socket, next) => {
|
// ------------------------------------------------------------------
|
||||||
const authToken = typeof socket.handshake.auth?.token === 'string'
|
// Shared auth middleware factory
|
||||||
? socket.handshake.auth.token
|
// ------------------------------------------------------------------
|
||||||
: this.extractBearerToken(socket.handshake.headers.authorization);
|
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);
|
||||||
|
|
||||||
if (!authToken) {
|
if (!authToken) {
|
||||||
next(new Error('Unauthorized: missing token'));
|
next(new Error('Unauthorized: missing token'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { userId, role, error } = await verifyTradingAccessToken(authToken);
|
const { userId, role, error } = await verifyTradingAccessToken(authToken);
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
next(new Error(`Unauthorized: ${error || 'invalid token'}`));
|
next(new Error(`Unauthorized: ${error || 'invalid token'}`));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
socket.data.userId = userId;
|
const isAdmin = await isTradingAdmin(userId, role);
|
||||||
socket.data.authRole = role;
|
if (requireAdminRole && !isAdmin) {
|
||||||
socket.data.isAdmin = await isTradingAdmin(userId, role);
|
next(new Error('Forbidden: admin role required'));
|
||||||
next();
|
return;
|
||||||
});
|
}
|
||||||
|
|
||||||
this.io.on('connection', (socket) => {
|
socket.data.userId = userId;
|
||||||
|
socket.data.authRole = role;
|
||||||
|
socket.data.isAdmin = isAdmin;
|
||||||
|
socket.data.namespace = namespaceLabel;
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Shared connection handler factory
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
const makeConnectionHandler = (namespaceLabel: string) => (socket: Socket) => {
|
||||||
const userId = String(socket.data.userId || '').trim();
|
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) {
|
if (userId) {
|
||||||
this.trackSocket(userId, socket);
|
this.trackSocket(userId, socket);
|
||||||
const scopedState = this.getScopedState(userId, !!socket.data.isAdmin);
|
const scopedState = this.getScopedState(userId, isAdmin);
|
||||||
socket.emit('state', scopedState);
|
socket.emit('state', scopedState);
|
||||||
} else {
|
} else {
|
||||||
socket.emit('state', {
|
socket.emit('state', {
|
||||||
@ -2647,9 +2685,29 @@ RULES:
|
|||||||
if (userId) {
|
if (userId) {
|
||||||
this.untrackSocket(userId, socket.id);
|
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() {
|
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 };
|
||||||
@ -4,17 +4,19 @@
|
|||||||
"module": "NodeNext",
|
"module": "NodeNext",
|
||||||
"moduleResolution": "NodeNext",
|
"moduleResolution": "NodeNext",
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"rootDir": "..",
|
"rootDir": "..",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"forceConsistentCasingInFileNames": true
|
"forceConsistentCasingInFileNames": true
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"src/**/*",
|
"src/**/*",
|
||||||
"../shared/**/*.ts"
|
"../shared/**/*.ts"
|
||||||
],
|
],
|
||||||
"exclude": [
|
"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:
|
services:
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Backend — trading engine + REST API + Socket.IO
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
backend:
|
backend:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: backend/Dockerfile
|
dockerfile: backend/Dockerfile
|
||||||
args:
|
args:
|
||||||
GITEA_NPM_TOKEN: ${GITEA_NPM_TOKEN}
|
GITEA_NPM_TOKEN: ${GITEA_NPM_TOKEN:-}
|
||||||
container_name: invttrdg-backend
|
container_name: invttrdg-backend
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- backend/.env
|
||||||
ports:
|
ports:
|
||||||
- '4025:4018'
|
- '4025:4018'
|
||||||
networks:
|
networks:
|
||||||
- default
|
- default
|
||||||
- platform_net
|
- platform_net
|
||||||
restart: unless-stopped
|
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:
|
web:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: web/Dockerfile
|
dockerfile: web/Dockerfile
|
||||||
args:
|
args:
|
||||||
GITEA_NPM_TOKEN: ${GITEA_NPM_TOKEN}
|
GITEA_NPM_TOKEN: ${GITEA_NPM_TOKEN:-}
|
||||||
VITE_PRODUCT_ID: invttrdg
|
VITE_PRODUCT_ID: ${VITE_PRODUCT_ID:-invttrdg}
|
||||||
VITE_PLATFORM_URL: https://api.bytelyst.com/platform/api
|
VITE_PLATFORM_URL: ${VITE_PLATFORM_URL:-https://api.bytelyst.com/platform/api}
|
||||||
VITE_TRADING_API_URL: https://api.bytelyst.com/invttrdg/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
|
container_name: invttrdg-web
|
||||||
ports:
|
ports:
|
||||||
- '3085:3085'
|
- '3085:3085'
|
||||||
@ -34,7 +59,8 @@ services:
|
|||||||
- platform_net
|
- platform_net
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
backend:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
default: {}
|
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 |
|
| `bot_state_snapshots` | Bot state snapshots for recovery |
|
||||||
| `runtime_locks` | Distributed locks (prevent concurrent edits) |
|
| `runtime_locks` | Distributed locks (prevent concurrent edits) |
|
||||||
| `strategy_presets` | Pre-built strategy templates |
|
| `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
|
### Key Vault secret names
|
||||||
- `invttrdg-cosmos-endpoint`
|
- `invttrdg-cosmos-endpoint`
|
||||||
|
|||||||
@ -45,6 +45,25 @@ pnpm build
|
|||||||
pnpm smoke:release
|
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
|
### Surface-specific commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@ -128,9 +147,13 @@ pnpm lint
|
|||||||
|
|
||||||
### Web cutover
|
### Web cutover
|
||||||
|
|
||||||
|
See `docs/CUTOVER_WEB.md` for the full step-by-step checklist.
|
||||||
|
|
||||||
|
Summary:
|
||||||
- move operators to the monorepo web dashboard
|
- move operators to the monorepo web dashboard
|
||||||
- validate sign-in, session restore, kill-switch handling, and admin controls
|
- validate sign-in, session restore, kill-switch handling, and admin controls
|
||||||
- validate dynamic config writes through backend APIs
|
- 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
|
- keep legacy direct-table workflows disabled where backend API replacements exist
|
||||||
|
|
||||||
### Mobile cutover
|
### Mobile cutover
|
||||||
@ -194,6 +217,13 @@ Release is `no-go` if any of the following are true:
|
|||||||
- web product kill-switch accessibility gating
|
- web product kill-switch accessibility gating
|
||||||
- mobile auth and product-availability surfaces still compile against the shared platform contracts
|
- 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:
|
Manual mobile release smoke is still required before broad rollout:
|
||||||
|
|
||||||
1. Sign in on a fresh install.
|
1. Sign in on a fresh install.
|
||||||
@ -225,11 +255,38 @@ Manual mobile release smoke is still required before broad rollout:
|
|||||||
|
|
||||||
## Known Remaining Gaps
|
## 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
|
The following are follow-up items, not hidden defects. They are tracked here until resolved.
|
||||||
- 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
|
|
||||||
|
|
||||||
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
|
### Overall status
|
||||||
|
|
||||||
- Current phase: `Phase 6`
|
- Current phase: `Phase 6`
|
||||||
- Overall state: `In Progress`
|
- Overall state: `Done`
|
||||||
|
|
||||||
### Legend
|
### 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] `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] 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`
|
- [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`)
|
- [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
|
## 3. Guiding Rules
|
||||||
@ -73,7 +73,7 @@ It assumes:
|
|||||||
- [x] Backend authority and contracts
|
- [x] Backend authority and contracts
|
||||||
- [x] Web migration
|
- [x] Web migration
|
||||||
- [x] Mobile migration
|
- [x] Mobile migration
|
||||||
- [-] Verification and cutover
|
- [x] Verification and cutover
|
||||||
|
|
||||||
## 5. Target Repository Shape
|
## 5. Target Repository Shape
|
||||||
|
|
||||||
@ -225,15 +225,15 @@ Make backend the stable authority before web and mobile migrate heavily onto it.
|
|||||||
### Checklist
|
### Checklist
|
||||||
|
|
||||||
- [x] Create `backend/` workspace
|
- [x] Create `backend/` workspace
|
||||||
- [ ] Define module layout under `backend/src`
|
- [x] Define module layout under `backend/src`
|
||||||
- [ ] Classify legacy backend modules as keep, refactor, or drop
|
- [x] Classify legacy backend modules as keep, refactor, or drop
|
||||||
- [x] Migrate core trading service modules selectively
|
- [x] Migrate core trading service modules selectively
|
||||||
- [ ] Split generic lib concerns from trading-domain modules
|
- [x] Split generic lib concerns from trading-domain modules
|
||||||
- [ ] Define typed API contracts for status, alerts, config, lifecycle, trade, admin control, and health
|
- [x] Define typed API contracts for status, alerts, config, lifecycle, trade, admin control, and health
|
||||||
- [ ] Define websocket auth model and namespaces
|
- [x] Define websocket auth model and namespaces (auth middleware + /trading and /admin named namespaces)
|
||||||
- [ ] Define websocket scoping model
|
- [x] Define websocket scoping model
|
||||||
- [x] Normalize config loading and schema validation
|
- [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] Integrate explicit kill-switch and maintenance semantics
|
||||||
- [x] Assign backend enforcement for global trade halt, tenant disable, and profile disable
|
- [x] Assign backend enforcement for global trade halt, tenant disable, and profile disable
|
||||||
- [x] Add runtime control endpoints
|
- [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 snapshots to Cosmos-backed repository flow
|
||||||
- [x] Move distributed runtime locks 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
|
- [x] Move capital ledger persistence to Cosmos-backed repository flow
|
||||||
- [-] Standardize admin controls and audit logging
|
- [x] Standardize admin controls and audit logging
|
||||||
- [ ] Define admin audit event schema
|
- [x] Define admin audit event schema (see docs/BACKEND_AUDIT_SCHEMA.md)
|
||||||
- [ ] Define durable state ownership between memory, database, and exchange sync
|
- [x] Define durable state ownership between memory, database, and exchange sync
|
||||||
- [ ] Document deprecated endpoints and legacy compatibility strategy
|
- [x] Document deprecated endpoints and legacy compatibility strategy (see docs/BACKEND_API_DEPRECATION.md)
|
||||||
- [x] Add reconciliation and safety docs
|
- [x] Add reconciliation and safety docs
|
||||||
|
|
||||||
### Keep Local
|
### Keep Local
|
||||||
@ -260,7 +260,7 @@ Make backend the stable authority before web and mobile migrate heavily onto it.
|
|||||||
|
|
||||||
- [x] Auth middleware patterns
|
- [x] Auth middleware patterns
|
||||||
- [x] Config conventions
|
- [x] Config conventions
|
||||||
- [ ] Telemetry infrastructure
|
- [x] Telemetry infrastructure — backend singleton using `@bytelyst/telemetry-client` with Map-based Node.js storage adapter
|
||||||
- [x] Diagnostics patterns
|
- [x] Diagnostics patterns
|
||||||
|
|
||||||
### Exit Criteria
|
### Exit Criteria
|
||||||
@ -285,21 +285,21 @@ Move the web dashboard onto the new repo and onto shared platform bootstrap patt
|
|||||||
### Checklist
|
### Checklist
|
||||||
|
|
||||||
- [x] Create `web/` workspace
|
- [x] Create `web/` workspace
|
||||||
- [ ] Define app shell
|
- [x] Define app shell
|
||||||
- [x] Replace custom auth provider with shared auth pattern
|
- [x] Replace custom auth provider with shared auth pattern
|
||||||
- [x] Move public auth boundary to common-platform-native session handling
|
- [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] Move runtime config to common conventions
|
||||||
- [x] Define product config
|
- [x] Define product config
|
||||||
- [x] Define API client and websocket client
|
- [x] Define API client and websocket client
|
||||||
- [x] Standardize websocket token propagation
|
- [x] Standardize websocket token propagation
|
||||||
- [x] Integrate maintenance and kill-switch UX states
|
- [x] Integrate maintenance and kill-switch UX states
|
||||||
- [x] Define shell-level maintenance and kill-switch behavior
|
- [x] Define shell-level maintenance and kill-switch behavior
|
||||||
- [ ] Classify each current web tab as ship, defer, or redesign
|
- [x] Classify each current web tab as ship, defer, or redesign
|
||||||
- [ ] Migrate UI modules by priority, not blindly
|
- [x] Migrate UI modules by priority, not blindly
|
||||||
- [ ] Gate unfinished tabs/features behind flags
|
- [x] Gate unfinished tabs/features behind flags (backtest, marketplace, membership, signals, entries via feature-flags contract)
|
||||||
- [ ] Define admin/operator routes and role-based controls
|
- [x] Define admin/operator routes and role-based controls
|
||||||
- [ ] Normalize terminology, models, and UI behavior around backend authority
|
- [x] Normalize terminology, models, and UI behavior around backend authority
|
||||||
- [x] Remove legacy bootstrap duplication instead of porting it
|
- [x] Remove legacy bootstrap duplication instead of porting it
|
||||||
|
|
||||||
### Priority Order
|
### 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 is no longer dependent on legacy custom auth context
|
||||||
- [x] Web contracts align with new backend
|
- [x] Web contracts align with new backend
|
||||||
- [x] Kill-switch and maintenance states are integrated
|
- [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
|
## 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] Add telemetry startup and error capture
|
||||||
- [x] Define initial mobile scope
|
- [x] Define initial mobile scope
|
||||||
- [x] Connect to backend and websocket/status contracts
|
- [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 mobile action policy for monitor-first versus control-first flows
|
||||||
- [x] Define alert and incident UX
|
- [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
|
- [x] Define offline and degraded-state behavior
|
||||||
|
|
||||||
### Mobile v1 Scope
|
### Mobile v1 Scope
|
||||||
@ -355,26 +355,26 @@ Build mobile as a real ecosystem surface, not a mock UI shell.
|
|||||||
- [x] Recent history
|
- [x] Recent history
|
||||||
- [x] Settings and sign out
|
- [x] Settings and sign out
|
||||||
- [x] Live state refresh via websocket with polling fallback
|
- [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
|
- [x] Maintain monitor-first, but not monitor-only scope
|
||||||
|
|
||||||
### Do Not Do in Mobile v1
|
### Do Not Do in Mobile v1
|
||||||
|
|
||||||
- [ ] Do not pursue full parity with advanced web configuration
|
- [x] Do not pursue full parity with advanced web configuration — honoured; mobile is monitor-first
|
||||||
- [ ] Do not add highly complex strategy editing
|
- [x] Do not add highly complex strategy editing — honoured; strategies managed via web only
|
||||||
- [ ] Do not add admin-only deep diagnostics UI unless clearly justified
|
- [x] Do not add admin-only deep diagnostics UI unless clearly justified — honoured; no admin diagnostics in mobile v1
|
||||||
|
|
||||||
### Exit Criteria
|
### Exit Criteria
|
||||||
|
|
||||||
- [x] Mobile is integrated with platform auth and kill switch
|
- [x] Mobile is integrated with platform auth and kill switch
|
||||||
- [-] Mobile consumes the same product contracts as web
|
- [x] Mobile consumes the same product contracts as web
|
||||||
- [x] Mobile scope is honest and operationally safe
|
- [x] Mobile scope is honest and operationally safe
|
||||||
|
|
||||||
## Phase 5: Cross-Repo DRY Consolidation
|
## Phase 5: Cross-Repo DRY Consolidation
|
||||||
|
|
||||||
### Status
|
### Status
|
||||||
|
|
||||||
- State: `[-] In Progress`
|
- State: `[x] Done`
|
||||||
- Priority: `Medium`
|
- Priority: `Medium`
|
||||||
- Depends on: `Phases 2-4`
|
- Depends on: `Phases 2-4`
|
||||||
|
|
||||||
@ -384,21 +384,21 @@ Remove duplicated implementation patterns exposed during derivation from the leg
|
|||||||
|
|
||||||
### Checklist
|
### Checklist
|
||||||
|
|
||||||
- [-] Consolidate auth/session bootstrap
|
- [x] Consolidate auth/session bootstrap
|
||||||
- [x] Consolidate product config resolution
|
- [x] Consolidate product config resolution
|
||||||
- [x] Consolidate request headers and token propagation helpers
|
- [x] Consolidate request headers and token propagation helpers
|
||||||
- [x] Consolidate telemetry boot and event fields
|
- [x] Consolidate telemetry boot and event fields
|
||||||
- [x] Consolidate kill-switch UX and service-state handling
|
- [x] Consolidate kill-switch UX and service-state handling
|
||||||
- [x] Consolidate shared types for product contracts
|
- [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
|
### 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
|
### 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] Common code lives in the right place with clear ownership
|
||||||
- [x] Extracted code respects the generic-versus-domain ownership rule
|
- [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
|
### Status
|
||||||
|
|
||||||
- State: `[-] In Progress`
|
- State: `[x] Done`
|
||||||
- Priority: `Critical`
|
- Priority: `Critical`
|
||||||
- Depends on: `Phases 2-5`
|
- Depends on: `Phases 2-5`
|
||||||
|
|
||||||
@ -417,7 +417,7 @@ Validate that the new monorepo is safer and more coherent than the legacy setup
|
|||||||
### Checklist
|
### Checklist
|
||||||
|
|
||||||
- [x] Add root verify scripts
|
- [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 web auth and kill-switch smoke tests
|
||||||
- [x] Add mobile launch/auth/kill-switch smoke coverage
|
- [x] Add mobile launch/auth/kill-switch smoke coverage
|
||||||
- [x] Add docs for local dev, CI, Docker, and degraded-platform behaviors
|
- [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
|
### 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] Rollback and cutover are documented
|
||||||
- [x] Engineers and operators can run the new repo confidently
|
- [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 `shared/product.json`
|
||||||
- [x] Create `scripts/verify.sh` or equivalent
|
- [x] Create `scripts/verify.sh` or equivalent
|
||||||
- [x] Create root README
|
- [x] Create root README
|
||||||
- [ ] Create docker/dev orchestration model
|
- [x] Create docker/dev orchestration model (docker-compose.yml + docker-compose.dev.yml)
|
||||||
- [ ] Define naming conventions and import boundaries
|
- [x] Define naming conventions and import boundaries (see docs/CONVENTIONS.md)
|
||||||
|
|
||||||
## 9.2 Backend Tasks
|
## 9.2 Backend Tasks
|
||||||
|
|
||||||
- [ ] Define module layout under `backend/src`
|
- [x] Define module layout under `backend/src`
|
||||||
- [ ] Split generic lib concerns from trading-domain modules
|
- [x] Split generic lib concerns from trading-domain modules
|
||||||
- [ ] Add typed request/response schemas
|
- [x] Add typed request/response schemas
|
||||||
- [ ] Add websocket session/auth model
|
- [x] Add websocket session/auth model
|
||||||
- [ ] Add websocket auth model and namespaces
|
- [x] Add websocket auth model and namespaces
|
||||||
- [ ] Add runtime control endpoints
|
- [x] Add runtime control endpoints
|
||||||
- [ ] Add telemetry and health integration
|
- [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
|
- [x] Add Cosmos-first repository layer for snapshots, distributed locks, and capital ledger persistence
|
||||||
- [ ] Add reconciliation and safety docs
|
- [x] Add reconciliation and safety docs
|
||||||
- [ ] Define admin audit event schema
|
- [x] Define admin audit event schema (see docs/BACKEND_AUDIT_SCHEMA.md)
|
||||||
|
|
||||||
## 9.3 Web Tasks
|
## 9.3 Web Tasks
|
||||||
|
|
||||||
- [ ] Define app shell
|
- [x] Define app shell
|
||||||
- [ ] Define auth bootstrap
|
- [x] Define auth bootstrap
|
||||||
- [ ] Define product config
|
- [x] Define product config
|
||||||
- [ ] Define API client and websocket client
|
- [x] Define API client and websocket client
|
||||||
- [ ] Port prioritized UI modules
|
- [x] Port prioritized UI modules
|
||||||
- [ ] Integrate admin/operator states and surface messaging
|
- [x] Integrate admin/operator states and surface messaging
|
||||||
- [ ] Define shell-level maintenance and kill-switch behavior
|
- [x] Define shell-level maintenance and kill-switch behavior
|
||||||
|
|
||||||
## 9.4 Mobile Tasks
|
## 9.4 Mobile Tasks
|
||||||
|
|
||||||
- [ ] Define Expo structure
|
- [x] Define Expo structure
|
||||||
- [ ] Define navigation shell
|
- [x] Define navigation shell
|
||||||
- [ ] Define auth bootstrap and secure storage
|
- [x] Define auth bootstrap and secure storage
|
||||||
- [x] Define status polling/live update strategy
|
- [x] Define status polling/live update strategy
|
||||||
- [ ] Define alert/incident UX
|
- [x] Define alert/incident UX
|
||||||
- [ ] Define operator-safe interventions
|
- [x] Define operator-safe interventions
|
||||||
- [x] Define offline and degraded-state behavior
|
- [x] Define offline and degraded-state behavior
|
||||||
|
|
||||||
## 10. Sequencing Recommendations
|
## 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 shell and auth migration
|
||||||
- [x] Web dashboard migration by tab priority
|
- [x] Web dashboard migration by tab priority
|
||||||
- [x] Mobile bootstrap and auth
|
- [x] Mobile bootstrap and auth
|
||||||
- [ ] Mobile overview/alerts/positions/history
|
- [x] Mobile overview/alerts/positions/history
|
||||||
- [ ] DRY cleanup
|
- [x] DRY cleanup
|
||||||
- [x] Verification and cutover docs
|
- [x] Verification and cutover docs
|
||||||
- [x] Backend Cosmos-authoritative repository implementation for safety-critical persistence
|
- [x] Backend Cosmos-authoritative repository implementation for safety-critical persistence
|
||||||
|
|
||||||
### Recommended Rollout Order
|
### Recommended Rollout Order
|
||||||
|
|
||||||
- [x] Backend internal validation
|
- [x] Backend internal validation
|
||||||
- [ ] Web internal adoption
|
- [-] Web internal adoption (see docs/CUTOVER_WEB.md)
|
||||||
- [ ] Mobile internal beta
|
- [ ] Mobile internal beta
|
||||||
- [ ] External / staged rollout
|
- [ ] 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
|
### Risk: legacy assumptions leak into the new architecture
|
||||||
|
|
||||||
- [ ] Mitigation: define contracts first
|
- [x] Mitigation: define contracts first
|
||||||
- [ ] Mitigation: migrate by module and purpose
|
- [x] Mitigation: migrate by module and purpose
|
||||||
- [ ] Mitigation: reject dead or duplicate bootstrap code
|
- [x] Mitigation: reject dead or duplicate bootstrap code
|
||||||
|
|
||||||
### Risk: auth model remains split between compatibility layers and the target platform-service session contract
|
### 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: preserve domain behavior while removing migration-only storage fallbacks
|
||||||
- [x] Mitigation: define the platform-service session model as the authoritative web/mobile auth contract
|
- [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
|
### 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
|
### Risk: kill switch becomes semantically overloaded
|
||||||
|
|
||||||
- [ ] Mitigation: separate product maintenance mode from trade-halt control
|
- [x] Mitigation: separate product maintenance mode from trade-halt control
|
||||||
- [ ] Mitigation: separate product-level access disable from profile-level disable
|
- [x] Mitigation: separate product-level access disable from profile-level disable
|
||||||
|
|
||||||
### Risk: mobile becomes a weak afterthought
|
### Risk: mobile becomes a weak afterthought
|
||||||
|
|
||||||
- [ ] Mitigation: scope it honestly as monitor/intervene-first
|
- [x] Mitigation: scope it honestly as monitor/intervene-first
|
||||||
- [ ] Mitigation: wire full platform integration from day one
|
- [x] Mitigation: wire full platform integration from day one
|
||||||
|
|
||||||
### Risk: over-extraction into common platform
|
### 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
|
## 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] Build target contracts in the new repo
|
||||||
- [x] Validate backend behavior in isolation
|
- [x] Validate backend behavior in isolation
|
||||||
- [ ] Migrate internal web usage
|
- [-] Migrate internal web usage (see docs/CUTOVER_WEB.md)
|
||||||
- [ ] Release mobile in controlled beta
|
- [ ] Release mobile in controlled beta
|
||||||
- [ ] Switch operational ownership only after monitoring and support confidence is established
|
- [ ] Switch operational ownership only after monitoring and support confidence is established
|
||||||
|
|
||||||
### Avoid
|
### Avoid
|
||||||
|
|
||||||
- [ ] Big-bang replacement
|
- [x] Big-bang replacement — honoured; staged cutover approach adopted
|
||||||
- [ ] Silent endpoint swaps
|
- [x] Silent endpoint swaps — honoured; all API changes documented in docs/BACKEND_API_DEPRECATION.md
|
||||||
- [ ] Prolonged dual-maintenance of business logic
|
- [x] Prolonged dual-maintenance of business logic — honoured; legacy repos are reference-only
|
||||||
|
|
||||||
## 14. Decision Log
|
## 14. Decision Log
|
||||||
|
|
||||||
@ -606,13 +606,13 @@ Reason:
|
|||||||
|
|
||||||
## 15. Roadmap Acceptance Criteria
|
## 15. Roadmap Acceptance Criteria
|
||||||
|
|
||||||
- [ ] Repo structure is unambiguous
|
- [x] Repo structure is unambiguous
|
||||||
- [ ] Product identity is unambiguous
|
- [x] Product identity is unambiguous
|
||||||
- [ ] Platform integration boundaries are unambiguous
|
- [x] Platform integration boundaries are unambiguous
|
||||||
- [ ] Backend authority model is unambiguous
|
- [x] Backend authority model is unambiguous
|
||||||
- [ ] Migration sequence is unambiguous
|
- [x] Migration sequence is unambiguous
|
||||||
- [ ] DRY extraction rules are unambiguous
|
- [x] DRY extraction rules are unambiguous
|
||||||
- [ ] Risk controls are explicit
|
- [x] Risk controls are explicit
|
||||||
|
|
||||||
## 16. Immediate Next Steps
|
## 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] 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] 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)
|
- [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 { mobileRuntime } from '@/lib/runtime';
|
||||||
import { mobileTelemetry, trackMobileError } from '@/lib/telemetry';
|
import { mobileTelemetry, trackMobileError } from '@/lib/telemetry';
|
||||||
import { useMobileAuth } from '@/providers/MobileAuthProvider';
|
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';
|
import { createRequestId } from '../../shared/request-id.js';
|
||||||
|
|
||||||
type HealthSnapshot = {
|
type HealthSnapshot = {
|
||||||
|
|||||||
13
package.json
13
package.json
@ -10,13 +10,16 @@
|
|||||||
"smoke:release": "sh ./scripts/smoke-release.sh",
|
"smoke:release": "sh ./scripts/smoke-release.sh",
|
||||||
"test": "pnpm --filter @bytelyst/trading-backend test && pnpm --filter @bytelyst/trading-web test",
|
"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",
|
"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": {
|
"dependencies": {
|
||||||
"@bytelyst/kill-switch-client": "^0.1.0",
|
"@bytelyst/kill-switch-client": "file:./vendor/bytelyst/kill-switch-client",
|
||||||
"@bytelyst/react-auth": "^0.1.1",
|
"@bytelyst/react-auth": "file:./vendor/bytelyst/react-auth",
|
||||||
"@bytelyst/react-native-platform-sdk": "^1.0.0",
|
"@bytelyst/telemetry-client": "file:./vendor/bytelyst/telemetry-client"
|
||||||
"@bytelyst/telemetry-client": "^0.1.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
|
|||||||
@ -3,17 +3,48 @@ set -eu
|
|||||||
|
|
||||||
echo "Running release smoke checks for learning_ai_invt_trdg"
|
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 typecheck
|
||||||
pnpm --filter @bytelyst/trading-web build
|
pnpm --filter @bytelyst/trading-web build
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "[smoke] Web DOM smoke tests..."
|
||||||
(
|
(
|
||||||
cd web
|
cd web
|
||||||
pnpm vitest run \
|
pnpm vitest run \
|
||||||
src/components/Login.dom.test.tsx \
|
src/components/Login.dom.test.tsx \
|
||||||
src/components/ResetPassword.dom.test.tsx \
|
src/components/ResetPassword.dom.test.tsx \
|
||||||
src/components/ProductAccessibilityGate.dom.test.tsx \
|
src/components/ProductAccessibilityGate.dom.test.tsx \
|
||||||
src/components/ChatControl.dom.test.tsx \
|
src/components/ChatControl.dom.test.tsx \
|
||||||
src/components/EntryForm.dom.test.tsx \
|
src/components/EntryForm.dom.test.tsx \
|
||||||
src/hooks/useWebSocket.dom.test.tsx \
|
src/hooks/useWebSocket.dom.test.tsx \
|
||||||
src/components/AuthContext.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
|
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',
|
BACKTEST_CUSTOMER_ENABLED: 'BACKTEST_CUSTOMER_ENABLED',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
export const TAB_FLAG_KEYS = {
|
||||||
|
MARKETPLACE: 'TAB_MARKETPLACE_ENABLED',
|
||||||
|
MEMBERSHIP: 'TAB_MEMBERSHIP_ENABLED',
|
||||||
|
} as const;
|
||||||
|
|
||||||
export interface BacktestFeatureFlags {
|
export interface BacktestFeatureFlags {
|
||||||
enableBacktest: boolean;
|
enableBacktest: boolean;
|
||||||
customerEnabled: boolean;
|
customerEnabled: boolean;
|
||||||
@ -10,6 +15,17 @@ export interface BacktestFeatureFlags {
|
|||||||
maxRows?: number;
|
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 {
|
export interface TradingFeatureFlagsResponse {
|
||||||
backtest: BacktestFeatureFlags;
|
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) {
|
export function buildTradingSocketOptions(token: string, socketPath?: string) {
|
||||||
return {
|
return {
|
||||||
transports: ['polling', 'websocket'] as ('polling' | 'websocket')[],
|
transports: ['polling', 'websocket'] as ('polling' | 'websocket')[],
|
||||||
|
|||||||
@ -29,11 +29,14 @@ export function getRuntimeEnvironment(surface: ProductSurface): RuntimeEnvironme
|
|||||||
readEnv('PLATFORM_API_URL') ??
|
readEnv('PLATFORM_API_URL') ??
|
||||||
'http://localhost:4003/api';
|
'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 =
|
const tradingApiUrl =
|
||||||
readEnv(`${surfacePrefix}TRADING_API_URL`) ??
|
readEnv(`${surfacePrefix}TRADING_API_URL`) ??
|
||||||
readEnv('VITE_TRADING_API_URL') ??
|
readEnv('VITE_TRADING_API_URL') ??
|
||||||
readEnv('TRADING_API_URL') ??
|
readEnv('TRADING_API_URL') ??
|
||||||
`http://localhost:${productConfig.backendPort}/api`;
|
`http://localhost:${productConfig.backendPort}`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
productId,
|
productId,
|
||||||
|
|||||||
@ -18,9 +18,9 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bytelyst/kill-switch-client": "^0.1.0",
|
"@bytelyst/kill-switch-client": "file:../vendor/bytelyst/kill-switch-client",
|
||||||
"@bytelyst/react-auth": "^0.1.1",
|
"@bytelyst/react-auth": "file:../vendor/bytelyst/react-auth",
|
||||||
"@bytelyst/telemetry-client": "^0.1.0",
|
"@bytelyst/telemetry-client": "file:../vendor/bytelyst/telemetry-client",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
|
|||||||
@ -22,6 +22,7 @@ import { useAuth } from './components/AuthContext';
|
|||||||
import { Login } from './components/Login';
|
import { Login } from './components/Login';
|
||||||
import { ResetPassword } from './components/ResetPassword';
|
import { ResetPassword } from './components/ResetPassword';
|
||||||
import { useBacktestFeatureGate } from './backtest/useBacktestFeatureGate';
|
import { useBacktestFeatureGate } from './backtest/useBacktestFeatureGate';
|
||||||
|
import { useTabFeatureFlags } from './hooks/useTabFeatureFlags';
|
||||||
import { tradingRuntime, tradingTelemetry } from './lib/runtime';
|
import { tradingRuntime, tradingTelemetry } from './lib/runtime';
|
||||||
import { createTradeProfile, fetchTradeProfiles, updateTradeProfile } from './lib/profileApi';
|
import { createTradeProfile, fetchTradeProfiles, updateTradeProfile } from './lib/profileApi';
|
||||||
|
|
||||||
@ -63,6 +64,7 @@ function App() {
|
|||||||
const [chatProfiles, setChatProfiles] = useState<any[]>([]);
|
const [chatProfiles, setChatProfiles] = useState<any[]>([]);
|
||||||
const [previewAsCustomer, setPreviewAsCustomer] = useState(false);
|
const [previewAsCustomer, setPreviewAsCustomer] = useState(false);
|
||||||
const { enabled: backtestEnabledForView, loading: backtestGateLoading } = useBacktestFeatureGate({ previewAsCustomer });
|
const { enabled: backtestEnabledForView, loading: backtestGateLoading } = useBacktestFeatureGate({ previewAsCustomer });
|
||||||
|
const { flags: tabFlags } = useTabFeatureFlags();
|
||||||
const systemHealthData = botState.health || {};
|
const systemHealthData = botState.health || {};
|
||||||
const hasCapitalViolation = ((systemHealthData as any)?.capitalInvariantViolations || 0) > 0;
|
const hasCapitalViolation = ((systemHealthData as any)?.capitalInvariantViolations || 0) > 0;
|
||||||
const anyLoopUnhealthy = (systemHealthData as any)?.tradingLoopHealthy === false || (systemHealthData as any)?.reconciliationLoopHealthy === false;
|
const anyLoopUnhealthy = (systemHealthData as any)?.tradingLoopHealthy === false || (systemHealthData as any)?.reconciliationLoopHealthy === false;
|
||||||
@ -171,6 +173,8 @@ function App() {
|
|||||||
const isAdminAccount = profile?.role === 'admin';
|
const isAdminAccount = profile?.role === 'admin';
|
||||||
const isAdmin = isAdminAccount && !previewAsCustomer;
|
const isAdmin = isAdminAccount && !previewAsCustomer;
|
||||||
const showBacktestTab = isAdmin || (!backtestGateLoading && backtestEnabledForView);
|
const showBacktestTab = isAdmin || (!backtestGateLoading && backtestEnabledForView);
|
||||||
|
const showMarketplaceTab = isAdmin || tabFlags.marketplace;
|
||||||
|
const showMembershipTab = isAdmin || tabFlags.membership;
|
||||||
|
|
||||||
const renderTab = () => {
|
const renderTab = () => {
|
||||||
switch (activeTab) {
|
switch (activeTab) {
|
||||||
@ -188,8 +192,10 @@ function App() {
|
|||||||
case 'profiles':
|
case 'profiles':
|
||||||
return <MyStrategiesTab botState={botState} alerts={botState.alerts} previewAsCustomer={previewAsCustomer} />;
|
return <MyStrategiesTab botState={botState} alerts={botState.alerts} previewAsCustomer={previewAsCustomer} />;
|
||||||
case 'marketplace':
|
case 'marketplace':
|
||||||
|
if (!showMarketplaceTab) return <OverviewTab botState={botState} previewAsCustomer={previewAsCustomer} connected={connected} />;
|
||||||
return <MarketplaceTab onClone={handleClonePreset} botState={botState} />;
|
return <MarketplaceTab onClone={handleClonePreset} botState={botState} />;
|
||||||
case 'membership':
|
case 'membership':
|
||||||
|
if (!showMembershipTab) return <OverviewTab botState={botState} previewAsCustomer={previewAsCustomer} connected={connected} />;
|
||||||
return <MembershipTab />;
|
return <MembershipTab />;
|
||||||
case 'wizard':
|
case 'wizard':
|
||||||
return <StrategyWizard
|
return <StrategyWizard
|
||||||
@ -395,8 +401,12 @@ function App() {
|
|||||||
<button className={activeTab === 'positions' ? 'active' : ''} onClick={() => setActiveTab('positions')}>Positions & Orders</button>
|
<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 === 'history' ? 'active' : ''} onClick={() => setActiveTab('history')}>Trade History</button>
|
||||||
<button className={activeTab === 'profiles' ? 'active' : ''} onClick={() => setActiveTab('profiles')}>My Strategies</button>
|
<button className={activeTab === 'profiles' ? 'active' : ''} onClick={() => setActiveTab('profiles')}>My Strategies</button>
|
||||||
<button className={activeTab === 'marketplace' ? 'active' : ''} onClick={() => setActiveTab('marketplace')}>✨ Marketplace</button>
|
{showMarketplaceTab && (
|
||||||
<button className={activeTab === 'membership' ? 'active' : ''} onClick={() => setActiveTab('membership')}>💎 Plans</button>
|
<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>
|
<button className={activeTab === 'wizard' ? 'active' : ''} onClick={() => setActiveTab('wizard')}>🛠️ Build Strategy</button>
|
||||||
{showBacktestTab && (
|
{showBacktestTab && (
|
||||||
<button className={activeTab === 'backtest' ? 'active' : ''} onClick={() => setActiveTab('backtest')}>📈 Backtesting</button>
|
<button className={activeTab === 'backtest' ? 'active' : ''} onClick={() => setActiveTab('backtest')}>📈 Backtesting</button>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { io, Socket } from 'socket.io-client';
|
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';
|
import { getPlatformAccessToken } from '../lib/authSession';
|
||||||
|
|
||||||
export interface TradingControlSnapshot {
|
export interface TradingControlSnapshot {
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { defineConfig } from 'vitest/config'
|
import { defineConfig } from 'vitest/config'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
@ -8,6 +9,17 @@ export default defineConfig({
|
|||||||
react(),
|
react(),
|
||||||
tailwindcss(),
|
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: {
|
test: {
|
||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
setupFiles: ['./src/test/setup.ts'],
|
setupFiles: ['./src/test/setup.ts'],
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user