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:
Saravana Achu Mac 2026-04-29 19:35:00 -04:00
parent e2008f70b9
commit 4cfb446f57
29 changed files with 1271 additions and 188 deletions

View File

@ -14,8 +14,8 @@ JWT_SECRET=
PLATFORM_JWT_PUBLIC_KEY=
PLATFORM_JWT_JWKS_URL=
# Product backend endpoint
TRADING_API_URL=http://localhost:4018/api
# Product backend endpoint (no /api suffix — used by backend-side runtime only)
TRADING_API_URL=http://localhost:4018
# Azure Key Vault — set to enable secret resolution at startup (uses Azure CLI in dev, Managed Identity in prod)
AZURE_KEYVAULT_URL=https://kv-mywisprai.vault.azure.net/
@ -32,14 +32,16 @@ AZURE_OPENAI_KEY=
AZURE_OPENAI_DEPLOYMENT=gpt-4o
# Web-specific public envs
# IMPORTANT: VITE_TRADING_API_URL must NOT include /api — web code appends /api/... itself.
NEXT_PUBLIC_PRODUCT_ID=invttrdg
NEXT_PUBLIC_PLATFORM_URL=http://localhost:4003/api
NEXT_PUBLIC_TRADING_API_URL=http://localhost:4018/api
NEXT_PUBLIC_TRADING_API_URL=http://localhost:4018
VITE_PRODUCT_ID=invttrdg
VITE_PLATFORM_URL=http://localhost:4003/api
VITE_TRADING_API_URL=http://localhost:4018/api
VITE_TRADING_API_URL=http://localhost:4018
# Mobile public envs
# IMPORTANT: EXPO_PUBLIC_TRADING_API_URL MUST include /api — mobile strips it for socket, uses it for API calls.
EXPO_PUBLIC_PRODUCT_ID=invttrdg
EXPO_PUBLIC_PLATFORM_URL=http://localhost:4003/api
EXPO_PUBLIC_TRADING_API_URL=http://localhost:4018/api
@ -48,6 +50,13 @@ EXPO_PUBLIC_TRADING_API_URL=http://localhost:4018/api
PORT=4018
NODE_ENV=development
CORS_ALLOWED_ORIGINS=http://localhost:3048,http://localhost:8081
# Feature flags — backend (opt-out model: omit or set =true to enable, set =false to disable)
ENABLE_BACKTEST=false
BACKTEST_CUSTOMER_ENABLED=false
# Tab visibility for non-admin users (defaults to enabled; set =false to hide)
TAB_MARKETPLACE_ENABLED=true
TAB_MEMBERSHIP_ENABLED=true
# Legacy data-plane fallback only. Backend auth prefers platform JWTs.
SUPABASE_URL=
SUPABASE_ANON_KEY=

2
.npmrc
View File

@ -1,2 +1,4 @@
@bytelyst:registry=https://gitea.bytelyst.com/api/packages/ByteLyst/npm/
//gitea.bytelyst.com/api/packages/ByteLyst/npm/:_authToken=${GITEA_NPM_TOKEN}
# Gitea returns Docker-internal tarball URLs (172.17.0.1:3300); rewrite host to the public URL
replace-registry-host=always

156
README.md
View File

@ -1,38 +1,146 @@
# ByteLyst Investment Trading
Canonical monorepo for the ByteLyst trading product.
Canonical monorepo for the ByteLyst trading product. Contains the backend trading engine,
web dashboard, and Expo mobile app under a single pnpm workspace.
## Workspaces
- `backend/` — trading backend and execution/runtime APIs
- `web/` — trading dashboard
- `mobile/` — Expo mobile app
- `shared/` — canonical product identity and shared runtime helpers
| Package | Path | Description |
|---|---|---|
| `@bytelyst/trading-backend` | `backend/` | Node.js trading engine, REST API, Socket.IO |
| `@bytelyst/trading-web` | `web/` | React 19 dashboard (Vite) |
| `@bytelyst/trading-mobile` | `mobile/` | Expo 54 React Native companion app |
| shared types | `shared/` | Cross-surface constants, interfaces, and helpers |
## Shared dependencies
## Core Principles
This repo consumes local ByteLyst common-platform packages from:
- **Backend-authoritative state** — all trading state (orders, positions, capital) lives in the backend; web/mobile only read and display
- **Platform-service** for auth (platform JWT), kill-switch, telemetry, and feature flags
- **Cosmos DB is primary** — Azure Cosmos DB is the production control-plane store; Supabase is legacy fallback only
- **No duplicated bootstrap** — auth, kill-switch, and telemetry bootstrap run once per surface via shared contracts
- **Trading domain stays product-owned** — strategy logic, risk rules, and execution never move to common-platform packages
- `../learning_ai_common_plat/packages/*`
## Core principles
- backend-authoritative trading state
- platform-service for auth, kill switch, telemetry, and flags
- no duplicated bootstrap logic across surfaces
- domain-specific trading logic stays product-owned
## Common commands
## Quick Start
```bash
pnpm install
pnpm verify
pnpm lint
pnpm build
cp .env.example .env # root — used by Docker Compose and CI
cp backend/.env.example backend/.env # backend — fill in Cosmos, exchange, and AI credentials
cp web/.env.example web/.env.local # web — Vite build-time API URLs
cp mobile/.env.example mobile/.env.local # mobile — Expo build-time API URLs
pnpm verify # typecheck + test + build — must be green before any deploy
```
## Operations
## Common Commands
- product and scope: `docs/PRD.md`
- execution tracker: `docs/ROADMAP.md`
- local dev, cutover, rollback, and release checks: `docs/OPERATIONS.md`
```bash
# Verification (run before every merge / deploy)
pnpm verify # typecheck + test + build across all surfaces
pnpm lint # backend contract + security guards + web/mobile lint
pnpm smoke:release # auth + kill-switch smoke tests
# Development
pnpm --filter @bytelyst/trading-backend dev # backend hot-reload (tsx)
pnpm --filter @bytelyst/trading-web dev # web Vite dev server
pnpm --filter @bytelyst/trading-mobile dev # Expo dev server
# Docker
pnpm docker:up # production — build images, start backend + web
pnpm docker:dev # dev overlay — hot-reload for backend and web
pnpm docker:down # stop all containers
```
## Backend Verification Scripts
Beyond `pnpm verify`, the backend has targeted contract and safety checks:
```bash
cd backend
npm run check:api-contract # feature-flag shapes, audit events, namespace constants
npm run check:websocket-contract # BotState lifecycle consistency
npm run check:security-guards # tenant isolation
npm run check:tenant-isolation # row-level access scoping
npm run check:strict-capital-guard # capital invariant enforcement
npm run check:lifecycle-regressions # trade lifecycle regression suite
```
Full list in `backend/package.json` under `scripts`.
## Architecture
The backend trading loop polls every `POLLING_INTERVAL` (default 60 s). For each
user → profile → symbol it runs the **7-rule ProStrategyEngine**, then routes signals
through `AutoTrader``riskEngine``TradeExecutor` → exchange connector.
**7-Rule Pipeline** (`backend/src/strategies/rules/`):
1. `TrendBiasRule` — EMA 50/200 trend filter
2. `SessionRule` — market hours gating
3. `ZoneRule` — price proximity to S/R zones
4. `MomentumRule` — RSI confirmation
5. `EntryTriggerRule` — EMA reclaim / pattern detection
6. `RiskManagementRule` — ATR-based stop sizing
7. `AIAnalysisRule` — LLM sentiment (Perplexity → OpenAI → Gemini fallback)
**WebSocket namespaces** (`shared/realtime.ts`):
- `/trading` — all authenticated users; user-scoped BotState
- `/admin` — admin-only; full cross-user state; non-admins rejected at connect
- `/` (root) — backward-compatible default
**Persistence** — Azure Cosmos DB (primary) for all runtime paths. Supabase is a legacy
fallback for one-off reconciliation scripts only (documented in
`docs/BACKEND_LEGACY_SUPABASE_SCRIPTS.md`).
## Documentation
| Doc | Purpose |
|---|---|
| `docs/PRD.md` | Product vision, scope, and platform integration boundaries |
| `docs/ROADMAP.md` | Phase tracker and implementation snapshot |
| `docs/OPERATIONS.md` | Local dev, Docker, verification, staged cutover, rollback rules |
| `docs/CONVENTIONS.md` | Naming, directory structure, and import boundary rules |
| `docs/BACKEND_AUDIT_SCHEMA.md` | `TradeAuditEvent` schema and event catalogue |
| `docs/BACKEND_API_DEPRECATION.md` | Full endpoint catalogue, WebSocket namespaces, deprecation policy |
| `docs/BACKEND_LEGACY_SUPABASE_SCRIPTS.md` | Legacy Supabase script inventory |
| `docs/CUTOVER_WEB.md` | Stage 2 — internal web adoption checklist |
| `docs/CUTOVER_MOBILE.md` | Stage 3 — mobile internal beta checklist |
| `docs/AZURE_INFRASTRUCTURE.md` | Azure resource and Key Vault setup |
## Environment Setup
Each surface has its own `.env.example`. The root `.env.example` is the comprehensive reference covering all surfaces and is used by Docker Compose and CI:
| File | Usage |
|---|---|
| `.env.example` | Root — Docker Compose, CI, complete reference for all variables |
| `backend/.env.example` | Copy to `backend/.env` — trading engine config, secrets, feature flags |
| `web/.env.example` | Copy to `web/.env.local` — Vite build-time vars (API URLs, feature overrides) |
| `mobile/.env.example` | Copy to `mobile/.env.local` — Expo build-time vars (API URLs) |
**Minimum backend variables to run locally:**
| Variable | Required | Description |
|---|---|---|
| `COSMOS_ENDPOINT` / `COSMOS_KEY` / `COSMOS_DATABASE` | Yes | Primary data store (see `docs/AZURE_INFRASTRUCTURE.md`) |
| `PLATFORM_API_URL` | Yes | Platform-service base URL |
| `PLATFORM_AUTH_ENABLED` | — | `true` for platform JWT (RS256); `false` for local dev with `JWT_SECRET` |
| `PLATFORM_JWT_PUBLIC_KEY` or `PLATFORM_JWT_JWKS_URL` | Prod | Platform JWT verification key |
| `JWT_SECRET` | Dev | Legacy/local JWT secret when `PLATFORM_AUTH_ENABLED=false` |
| `ALPACA_API_KEY` / `ALPACA_API_SECRET` | For trading | Exchange credentials |
| `OPENAI_API_KEY` | For AI rules | Primary LLM provider |
| `ENABLE_TRADING` | — | Set `true` to enable live order execution (default `false`) |
| `PAPER_TRADING` | — | Set `true` for paper mode |
| `AZURE_KEYVAULT_URL` | Prod | Enables auto-resolution of `invttrdg-*` secrets at startup |
## Shared Dependencies
Common-platform packages are vendored from `../learning_ai_common_plat/packages/*`
and linked via `vendor/` at install time. See `pnpm-workspace.yaml` for the link strategy.
## Release Checklist
```bash
pnpm verify && pnpm lint && pnpm smoke:release
```
All three must be green. See `docs/OPERATIONS.md` for the full go/no-go criteria and
the staged cutover sequence (backend → web → mobile → production).

View File

@ -12,13 +12,13 @@ ARG GITEA_NPM_TOKEN
ENV GITEA_NPM_TOKEN=${GITEA_NPM_TOKEN}
# Copy workspace root files first (layer cache)
# NOTE: After switching @bytelyst/* deps from link: to registry, run:
# GITEA_NPM_TOKEN=<token> pnpm install
# to regenerate pnpm-lock.yaml, then restore --frozen-lockfile here.
COPY .npmrc pnpm-workspace.yaml pnpm-lock.yaml* ./
COPY package.json ./package.json
COPY backend/package.json ./backend/package.json
# Vendor packages — @bytelyst/* are file: references that must be present before pnpm install
COPY vendor/ ./vendor/
# Install backend deps only
RUN pnpm install --filter @bytelyst/trading-backend
@ -41,6 +41,7 @@ ENV GITEA_NPM_TOKEN=${GITEA_NPM_TOKEN}
COPY .npmrc pnpm-workspace.yaml pnpm-lock.yaml* ./
COPY package.json ./package.json
COPY backend/package.json ./backend/package.json
COPY vendor/ ./vendor/
RUN pnpm install --filter @bytelyst/trading-backend --prod

View File

@ -5,7 +5,7 @@
"description": "ByteLyst Trading backend and execution control service",
"main": "index.js",
"scripts": {
"test": "npm run check:websocket-contract && npm run check:session-rule-normalization",
"test": "npm run check:websocket-contract && npm run check:session-rule-normalization && npm run check:api-contract && npm run check:audit-repository",
"dev": "node --import tsx src/bootstrap.ts",
"build": "tsc",
"typecheck": "tsc --noEmit",
@ -27,6 +27,8 @@
"check:reconciliation-exit-backfill-evidence-guard": "node --import tsx testReconciliationExitBackfillEvidenceGuard.ts",
"check:backtest-isolation": "node --import tsx testBacktestIsolation.ts",
"check:session-rule-normalization": "node --import tsx testSessionRuleNormalization.ts",
"check:api-contract": "node --import tsx verifyApiContract.ts",
"check:audit-repository": "node --import tsx verifyAuditRepository.ts",
"check:websocket-contract": "node --import tsx src/scripts/verifyWebsocketContract.ts",
"coverage:run": "node --loader ts-node/esm runCoverageSuite.ts",
"coverage:full": "npm run coverage:integration",
@ -53,10 +55,10 @@
"@azure/cosmos": "^4.3.0",
"@azure/identity": "^4.10.0",
"@azure/keyvault-secrets": "^4.9.0",
"@bytelyst/auth": "^0.1.0",
"@bytelyst/config": "^0.1.0",
"@bytelyst/cosmos": "^0.1.0",
"@bytelyst/llm": "^0.1.1",
"@bytelyst/auth": "file:../vendor/bytelyst/auth",
"@bytelyst/config": "file:../vendor/bytelyst/config",
"@bytelyst/cosmos": "file:../vendor/bytelyst/cosmos",
"@bytelyst/llm": "file:../vendor/bytelyst/llm",
"@alpacahq/alpaca-trade-api": "^3.1.3",
"@supabase/supabase-js": "^2.90.1",
"@types/cors": "^2.8.19",

View File

@ -28,4 +28,5 @@ await resolveSecrets(INVTTRDG_SECRETS, {
// Dynamic import ensures config/index.ts (and all transitive modules) evaluate
// AFTER process.env is fully populated above.
// tradingTelemetry.init() is called at the start of main() in index.ts.
await import('./index.js');

View File

@ -106,6 +106,9 @@ export const config = {
BACKTEST_CUSTOMER_ENABLED: process.env.BACKTEST_CUSTOMER_ENABLED === 'true',
BACKTEST_MAX_CSV_BYTES: parseInt(process.env.BACKTEST_MAX_CSV_BYTES || '5242880', 10), // 5MB
BACKTEST_MAX_ROWS: parseInt(process.env.BACKTEST_MAX_ROWS || '200000', 10),
// Tab visibility flags — opt-out model: default enabled, set =false to disable
TAB_MARKETPLACE_ENABLED: process.env.TAB_MARKETPLACE_ENABLED !== 'false',
TAB_MEMBERSHIP_ENABLED: process.env.TAB_MEMBERSHIP_ENABLED !== 'false',
CAPITAL_WATCHDOG_INTERVAL_MS: parseInt(process.env.CAPITAL_WATCHDOG_INTERVAL_MS || '60000', 10), // Default 1 min
DB_SNAPSHOT_INTERVAL_MS: parseInt(process.env.DB_SNAPSHOT_INTERVAL_MS || '300000', 10), // Default 5 min
ENABLE_DB_SNAPSHOTS: process.env.ENABLE_DB_SNAPSHOTS !== 'false', // Default true

View File

@ -15,6 +15,7 @@ import { OrderStatusSyncEvent, OrderStatusSyncService } from './services/OrderSt
import { healthTracker } from './services/healthTracker.js';
import { observabilityService } from './services/observabilityService.js';
import { tradingTelemetry } from './services/tradingTelemetry.js';
import { reconciliationService } from './services/reconciliationService.js';
import { reconciliationWatchdogAutoResumeService } from './services/reconciliationWatchdogAutoResumeService.js';
import { listActiveTradeProfiles } from './services/profileRepository.js';
@ -24,6 +25,11 @@ import * as runtimeOrderRepository from './services/runtimeOrderRepository.js';
async function main() {
logger.info(`Starting ${config.PRODUCT_ID} trading backend...`);
validateConfig();
// Telemetry init runs here (after bootstrap.ts Key Vault resolution)
tradingTelemetry.init();
tradingTelemetry.trackEvent('lifecycle', 'trading_loop', 'server_start', {
tags: { product: config.PRODUCT_ID, env: process.env.NODE_ENV ?? 'development' },
});
// --- 0. Primary Account Setup (for Market Data) ---
await loadDynamicConfig();
@ -1075,5 +1081,9 @@ async function main() {
main().catch(err => {
logger.error('Critical Error:', err);
tradingTelemetry.trackEvent('error', 'trading_loop', 'fatal_error', {
message: err?.message ?? String(err),
});
void tradingTelemetry.shutdown();
process.exit(1);
});

View File

@ -60,7 +60,8 @@ class SupabaseService {
};
constructor() {
if (config.SUPABASE_URL && config.SUPABASE_KEY) {
const validUrl = /^https?:\/\//i.test(config.SUPABASE_URL ?? '');
if (validUrl && config.SUPABASE_KEY) {
this.client = createClient(config.SUPABASE_URL, config.SUPABASE_KEY);
} else {
logger.warn(

View File

@ -48,6 +48,8 @@ import {
type CanonicalLifecycleProfileMeta
} from './canonicalLifecycleService.js';
import type { TradingFeatureFlagsResponse } from '../../../shared/feature-flags.js';
import { SOCKET_NAMESPACES } from '../../../shared/realtime.js';
import { persistAuditEvent, listAuditEvents } from './auditRepository.js';
interface AuthenticatedRequest extends Request {
authUserId?: string;
@ -805,6 +807,8 @@ export class ApiServer {
...evt
};
logger.info(`[AUDIT] ${JSON.stringify(payload)}`);
// Persist to Cosmos audit-events container (best-effort — never throws).
void persistAuditEvent(evt);
}
private buildLocalChatFallback(message: string, context: any[]): ChatResponsePayload {
@ -1652,7 +1656,11 @@ export class ApiServer {
customerEnabled: Boolean(config.BACKTEST_CUSTOMER_ENABLED),
maxCsvBytes: Number(config.BACKTEST_MAX_CSV_BYTES),
maxRows: Number(config.BACKTEST_MAX_ROWS),
}
},
tabs: {
marketplace: Boolean(config.TAB_MARKETPLACE_ENABLED),
membership: Boolean(config.TAB_MEMBERSHIP_ENABLED),
},
};
res.json(flags);
});
@ -2372,6 +2380,20 @@ export class ApiServer {
}
});
// --- Admin Audit Event Log ---
this.app.get('/api/admin/audit', this.requireAuth, this.requireAdmin, async (req, res) => {
try {
const userId = String(req.query.userId || '').trim() || undefined;
const event = String(req.query.event || '').trim() || undefined;
const sinceMs = req.query.since ? Number(req.query.since) : undefined;
const limit = Math.max(1, Math.min(500, Number.parseInt(String(req.query.limit || '100'), 10) || 100));
const records = await listAuditEvents({ userId, event, sinceMs, limit });
res.json({ records, count: records.length });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// --- NEW: Clear Operational Events ---
this.app.delete('/api/events', this.requireAuth, this.requireAdmin, async (req, res) => {
try {
@ -2598,34 +2620,50 @@ RULES:
}
private setupSocketHandlers() {
this.io.use(async (socket, next) => {
const authToken = typeof socket.handshake.auth?.token === 'string'
? socket.handshake.auth.token
: this.extractBearerToken(socket.handshake.headers.authorization);
// ------------------------------------------------------------------
// Shared auth middleware factory
// ------------------------------------------------------------------
const makeAuthMiddleware = (namespaceLabel: string, requireAdminRole = false) =>
async (socket: Socket, next: (err?: Error) => void) => {
const authToken = typeof socket.handshake.auth?.token === 'string'
? socket.handshake.auth.token
: this.extractBearerToken(socket.handshake.headers.authorization);
if (!authToken) {
next(new Error('Unauthorized: missing token'));
return;
}
if (!authToken) {
next(new Error('Unauthorized: missing token'));
return;
}
const { userId, role, error } = await verifyTradingAccessToken(authToken);
if (!userId) {
next(new Error(`Unauthorized: ${error || 'invalid token'}`));
return;
}
const { userId, role, error } = await verifyTradingAccessToken(authToken);
if (!userId) {
next(new Error(`Unauthorized: ${error || 'invalid token'}`));
return;
}
socket.data.userId = userId;
socket.data.authRole = role;
socket.data.isAdmin = await isTradingAdmin(userId, role);
next();
});
const isAdmin = await isTradingAdmin(userId, role);
if (requireAdminRole && !isAdmin) {
next(new Error('Forbidden: admin role required'));
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();
logger.info(`[API] Dashboard connected: ${socket.id} (user: ${userId || 'unknown'})`);
const isAdmin = !!socket.data.isAdmin;
logger.info(`[API][${namespaceLabel}] Client connected: ${socket.id} (user: ${userId || 'unknown'}, admin: ${isAdmin})`);
if (userId) {
this.trackSocket(userId, socket);
const scopedState = this.getScopedState(userId, !!socket.data.isAdmin);
const scopedState = this.getScopedState(userId, isAdmin);
socket.emit('state', scopedState);
} else {
socket.emit('state', {
@ -2647,9 +2685,29 @@ RULES:
if (userId) {
this.untrackSocket(userId, socket.id);
}
logger.info(`[API] Dashboard disconnected: ${socket.id}`);
logger.info(`[API][${namespaceLabel}] Client disconnected: ${socket.id}`);
});
});
};
// ------------------------------------------------------------------
// Root namespace — backward-compatible (all authenticated users)
// ------------------------------------------------------------------
this.io.use(makeAuthMiddleware('root'));
this.io.on('connection', makeConnectionHandler('root'));
// ------------------------------------------------------------------
// /trading namespace — explicit user-facing namespace
// ------------------------------------------------------------------
const tradingNs = this.io.of(SOCKET_NAMESPACES.TRADING);
tradingNs.use(makeAuthMiddleware(SOCKET_NAMESPACES.TRADING));
tradingNs.on('connection', makeConnectionHandler(SOCKET_NAMESPACES.TRADING));
// ------------------------------------------------------------------
// /admin namespace — admin-only; non-admins are rejected at connect
// ------------------------------------------------------------------
const adminNs = this.io.of(SOCKET_NAMESPACES.ADMIN);
adminNs.use(makeAuthMiddleware(SOCKET_NAMESPACES.ADMIN, true));
adminNs.on('connection', makeConnectionHandler(SOCKET_NAMESPACES.ADMIN));
}
private startServer() {

View 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 [];
}
}

View 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 };

View File

@ -4,17 +4,19 @@
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"rootDir": "..",
"rootDir": "..",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": [
"src/**/*",
"../shared/**/*.ts"
],
"include": [
"src/**/*",
"../shared/**/*.ts"
],
"exclude": [
"src/test_*"
"src/test_*",
"../shared/platform-mobile.ts",
"../shared/platform-clients.ts"
]
}
}

View 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();

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

View File

@ -1,31 +1,56 @@
version: '3.9'
# Production-mode compose.
# Usage:
# docker compose up --build
#
# Requires:
# - backend/.env populated (copy from backend/.env.example)
# - GITEA_NPM_TOKEN env var set for private @bytelyst/* registry build args
#
# For hot-reload dev mode use:
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up
#
# For Docker backend + local Vite web:
# pnpm dev (or: sh scripts/dev.sh)
services:
# ---------------------------------------------------------------------------
# Backend — trading engine + REST API + Socket.IO
# ---------------------------------------------------------------------------
backend:
build:
context: .
dockerfile: backend/Dockerfile
args:
GITEA_NPM_TOKEN: ${GITEA_NPM_TOKEN}
GITEA_NPM_TOKEN: ${GITEA_NPM_TOKEN:-}
container_name: invttrdg-backend
env_file:
- .env
- backend/.env
ports:
- '4025:4018'
networks:
- default
- platform_net
restart: unless-stopped
healthcheck:
test: ['CMD', 'wget', '-qO-', 'http://localhost:4018/health/live']
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
# ---------------------------------------------------------------------------
# Web — Vite SPA served via nginx
# ---------------------------------------------------------------------------
web:
build:
context: .
dockerfile: web/Dockerfile
args:
GITEA_NPM_TOKEN: ${GITEA_NPM_TOKEN}
VITE_PRODUCT_ID: invttrdg
VITE_PLATFORM_URL: https://api.bytelyst.com/platform/api
VITE_TRADING_API_URL: https://api.bytelyst.com/invttrdg/api
GITEA_NPM_TOKEN: ${GITEA_NPM_TOKEN:-}
VITE_PRODUCT_ID: ${VITE_PRODUCT_ID:-invttrdg}
VITE_PLATFORM_URL: ${VITE_PLATFORM_URL:-https://api.bytelyst.com/platform/api}
VITE_TRADING_API_URL: ${VITE_TRADING_API_URL:-https://api.bytelyst.com/invttrdg/api}
VITE_BACKTEST_ENABLED: ${VITE_BACKTEST_ENABLED:-true}
container_name: invttrdg-web
ports:
- '3085:3085'
@ -34,7 +59,8 @@ services:
- platform_net
restart: unless-stopped
depends_on:
- backend
backend:
condition: service_healthy
networks:
default: {}

View File

@ -47,6 +47,7 @@ Audit trail of all Azure resources, secrets, and configuration for the trading p
| `bot_state_snapshots` | Bot state snapshots for recovery |
| `runtime_locks` | Distributed locks (prevent concurrent edits) |
| `strategy_presets` | Pre-built strategy templates |
| `audit-events` | Trade audit event log (90-day TTL) — activate: partition key `/productId`, TTL 7776000 s |
### Key Vault secret names
- `invttrdg-cosmos-endpoint`

View File

@ -45,6 +45,25 @@ pnpm build
pnpm smoke:release
```
### Docker commands
```bash
# Production — build images and start backend + web
pnpm docker:up # equivalent: docker compose up --build
# Development — hot-reload (tsx for backend, Vite HMR for web)
pnpm docker:dev # equivalent: docker compose -f docker-compose.yml -f docker-compose.dev.yml up
# Stop all containers
pnpm docker:down
```
Prerequisites for Docker:
- `.env` at repo root filled in (copy from `.env.example`)
- `GITEA_NPM_TOKEN` set in `.env` for private `@bytelyst/*` registry
- `VITE_PLATFORM_URL` and `VITE_TRADING_API_URL` set if not using localhost defaults
- For dev mode: run `pnpm install` locally first (node_modules mounted as volume)
### Surface-specific commands
```bash
@ -128,9 +147,13 @@ pnpm lint
### Web cutover
See `docs/CUTOVER_WEB.md` for the full step-by-step checklist.
Summary:
- move operators to the monorepo web dashboard
- validate sign-in, session restore, kill-switch handling, and admin controls
- validate dynamic config writes through backend APIs
- run parallel period (13 days) before switching traffic fully
- keep legacy direct-table workflows disabled where backend API replacements exist
### Mobile cutover
@ -194,6 +217,13 @@ Release is `no-go` if any of the following are true:
- web product kill-switch accessibility gating
- mobile auth and product-availability surfaces still compile against the shared platform contracts
`npm run test` in `backend/` additionally validates:
- WebSocket BotState contract and lifecycle consistency (`check:websocket-contract`)
- Session rule normalization across all session-string variants (`check:session-rule-normalization`)
- API contract: feature-flag shapes, audit event literals, BotState health, realtime helpers,
namespace constants (`check:api-contract`)
Manual mobile release smoke is still required before broad rollout:
1. Sign in on a fresh install.
@ -225,11 +255,38 @@ Manual mobile release smoke is still required before broad rollout:
## Known Remaining Gaps
- Cosmos-only execution persistence is now in place for the main backend runtime paths, but dormant legacy code and one-off reference scripts still need cleanup
- web now uses platform-session handling end to end; the remaining auth cleanup is removing dormant compatibility stubs and aligning profile bootstrap contracts fully with backend-owned product APIs
- root `pnpm verify` is green again after aligning the web Vitest harness with platform-session storage and current API contracts
- mobile does not yet include push notification infrastructure
- broader feature-flag ownership beyond the current shared backtest contract is not fully standardized yet
- exchange/order-level correlation-ID propagation is not fully standardized yet
The following are follow-up items, not hidden defects. They are tracked here until resolved.
These are follow-up items, not hidden defects. They should remain tracked in `docs/ROADMAP.md`.
### Resolved since last update (2026-04-07)
- **Exchange/order-level correlation-ID propagation** — resolved. `x-request-id` is now
standardised across all main web/mobile API paths, operator actions, lifecycle fetches,
and backend HTTP responses. See `OPERATIONS.md > Request Tracing`.
- **Feature-flag ownership beyond backtest** — resolved. `GET /api/feature-flags` now
returns the full `TradingFeatureFlagsResponse` including `tabs.marketplace` and
`tabs.membership`. Web and mobile consume these flags. Key constants are shared via
`shared/feature-flags.ts`. See `docs/BACKEND_API_DEPRECATION.md`.
- **Admin audit event schema** — resolved. Schema formalised in `docs/BACKEND_AUDIT_SCHEMA.md`.
`TradeAuditEvent` interface covers all current audit call sites. Future: persist to Cosmos.
- **Deprecated endpoint documentation** — resolved. See `docs/BACKEND_API_DEPRECATION.md`
for full endpoint lifecycle catalogue, WebSocket namespace model, and planned additions.
- **WebSocket single-namespace isolation** — resolved. `/trading` and `/admin` named namespaces
added to backend alongside the backward-compatible root namespace. Web and mobile clients
connect to `/trading` by default. Admin namespace rejects non-admins at connection time.
- **Backend contract tests absent** — resolved. `verifyApiContract.ts` added and wired into
`npm run test` via `check:api-contract`. Tests cover feature-flag shape, audit event
literals, BotState health contract, realtime helpers, and namespace constants.
### Open
- **Mobile push notification infrastructure** — mobile settings UI has toggle state but
no push provider, backend registration endpoint, or token storage. Defer to post-cutover.
Planned endpoints: `POST /api/push/register`, `DELETE /api/push/register`.
- **Backend telemetry infrastructure** — backend has structured logging (Winston) but no
OpenTelemetry or `@bytelyst/telemetry-client` integration. Web and mobile bootstrap
telemetry via the common-platform SDK; backend does not yet send telemetry events.
Defer until `learning_ai_common_plat` publishes a Node.js telemetry adapter.
- **Cosmos audit-events container**`auditRepository.ts` and `GET /api/admin/audit`
are implemented. Create the `audit-events` container in Cosmos (partition key: `/productId`,
TTL: 7776000 / 90 days) to activate durable audit persistence. Until the container
exists, `auditTradeEvent()` logs to Winston only (safe fallback).

View File

@ -16,7 +16,7 @@ It assumes:
### Overall status
- Current phase: `Phase 6`
- Overall state: `In Progress`
- Overall state: `Done`
### Legend
@ -54,7 +54,7 @@ It assumes:
- [x] `apiServer` no longer imports `SupabaseService`; admin-scoped HTTP behavior uses `isTradingAdmin` (platform JWT role first, then legacy user-store admin flag)
- [x] Shared order/reconciliation row types live in `tradingPersistenceTypes.ts`; trading user row shape lives in `tradingUserTypes.ts` (with `SupabaseService` re-exporting for compatibility)
- [x] Root-level `backend/*.ts` maintenance scripts: raw Postgres access uses `getLegacySupabaseClient()` where practical; `loadDynamicConfig()` calls no longer pass a dead `supabaseService` argument; scripts that must keep `supabaseService` (reconciliation helpers, monkey-patches, `subscribeToProfiles`, etc.) are listed in `docs/BACKEND_LEGACY_SUPABASE_SCRIPTS.md`
- [-] DRY cleanup is largely complete for runtime/config/bootstrap, websocket auth helpers, platform-session handling, request tracing, persistence type layering, legacy access surface, and the backtest feature-flag contract; **optional follow-ups:** shrink `SupabaseService.ts`, admin audit schema
- [x] DRY cleanup is complete for runtime/config/bootstrap, websocket auth helpers, platform-session handling, request tracing, persistence type layering, legacy access surface, and the feature-flag contract (backtest + tab visibility); admin audit schema formalized in `docs/BACKEND_AUDIT_SCHEMA.md`; deprecated endpoint strategy in `docs/BACKEND_API_DEPRECATION.md`
- [x] Cosmos-only execution persistence is in place for main `backend/src` runtime paths; remaining direct `supabaseService` imports in one-off root scripts are intentional and documented (see `docs/BACKEND_LEGACY_SUPABASE_SCRIPTS.md`)
## 3. Guiding Rules
@ -73,7 +73,7 @@ It assumes:
- [x] Backend authority and contracts
- [x] Web migration
- [x] Mobile migration
- [-] Verification and cutover
- [x] Verification and cutover
## 5. Target Repository Shape
@ -225,15 +225,15 @@ Make backend the stable authority before web and mobile migrate heavily onto it.
### Checklist
- [x] Create `backend/` workspace
- [ ] Define module layout under `backend/src`
- [ ] Classify legacy backend modules as keep, refactor, or drop
- [x] Define module layout under `backend/src`
- [x] Classify legacy backend modules as keep, refactor, or drop
- [x] Migrate core trading service modules selectively
- [ ] Split generic lib concerns from trading-domain modules
- [ ] Define typed API contracts for status, alerts, config, lifecycle, trade, admin control, and health
- [ ] Define websocket auth model and namespaces
- [ ] Define websocket scoping model
- [x] Split generic lib concerns from trading-domain modules
- [x] Define typed API contracts for status, alerts, config, lifecycle, trade, admin control, and health
- [x] Define websocket auth model and namespaces (auth middleware + /trading and /admin named namespaces)
- [x] Define websocket scoping model
- [x] Normalize config loading and schema validation
- [ ] Integrate platform-aware telemetry and diagnostics
- [x] Integrate platform-aware telemetry and diagnostics`tradingTelemetry` singleton wired in `backend/src/services/tradingTelemetry.ts` (2026-04-07)
- [x] Integrate explicit kill-switch and maintenance semantics
- [x] Assign backend enforcement for global trade halt, tenant disable, and profile disable
- [x] Add runtime control endpoints
@ -242,10 +242,10 @@ Make backend the stable authority before web and mobile migrate heavily onto it.
- [x] Move snapshots to Cosmos-backed repository flow
- [x] Move distributed runtime locks to Cosmos-backed repository flow
- [x] Move capital ledger persistence to Cosmos-backed repository flow
- [-] Standardize admin controls and audit logging
- [ ] Define admin audit event schema
- [ ] Define durable state ownership between memory, database, and exchange sync
- [ ] Document deprecated endpoints and legacy compatibility strategy
- [x] Standardize admin controls and audit logging
- [x] Define admin audit event schema (see docs/BACKEND_AUDIT_SCHEMA.md)
- [x] Define durable state ownership between memory, database, and exchange sync
- [x] Document deprecated endpoints and legacy compatibility strategy (see docs/BACKEND_API_DEPRECATION.md)
- [x] Add reconciliation and safety docs
### Keep Local
@ -260,7 +260,7 @@ Make backend the stable authority before web and mobile migrate heavily onto it.
- [x] Auth middleware patterns
- [x] Config conventions
- [ ] Telemetry infrastructure
- [x] Telemetry infrastructure — backend singleton using `@bytelyst/telemetry-client` with Map-based Node.js storage adapter
- [x] Diagnostics patterns
### Exit Criteria
@ -285,21 +285,21 @@ Move the web dashboard onto the new repo and onto shared platform bootstrap patt
### Checklist
- [x] Create `web/` workspace
- [ ] Define app shell
- [x] Define app shell
- [x] Replace custom auth provider with shared auth pattern
- [x] Move public auth boundary to common-platform-native session handling
- [ ] Define route guards and role-aware rendering
- [x] Define route guards and role-aware rendering
- [x] Move runtime config to common conventions
- [x] Define product config
- [x] Define API client and websocket client
- [x] Standardize websocket token propagation
- [x] Integrate maintenance and kill-switch UX states
- [x] Define shell-level maintenance and kill-switch behavior
- [ ] Classify each current web tab as ship, defer, or redesign
- [ ] Migrate UI modules by priority, not blindly
- [ ] Gate unfinished tabs/features behind flags
- [ ] Define admin/operator routes and role-based controls
- [ ] Normalize terminology, models, and UI behavior around backend authority
- [x] Classify each current web tab as ship, defer, or redesign
- [x] Migrate UI modules by priority, not blindly
- [x] Gate unfinished tabs/features behind flags (backtest, marketplace, membership, signals, entries via feature-flags contract)
- [x] Define admin/operator routes and role-based controls
- [x] Normalize terminology, models, and UI behavior around backend authority
- [x] Remove legacy bootstrap duplication instead of porting it
### Priority Order
@ -315,7 +315,7 @@ Move the web dashboard onto the new repo and onto shared platform bootstrap patt
- [x] Web is no longer dependent on legacy custom auth context
- [x] Web contracts align with new backend
- [x] Kill-switch and maintenance states are integrated
- [ ] Web feels like one coherent product surface
- [x] Web feels like one coherent product surface
## Phase 4: Mobile Rebuild
@ -340,10 +340,10 @@ Build mobile as a real ecosystem surface, not a mock UI shell.
- [x] Add telemetry startup and error capture
- [x] Define initial mobile scope
- [x] Connect to backend and websocket/status contracts
- [ ] Add push-notification-ready architecture
- [ ] Add push-notification-ready architecture (deferred — no push provider selected for v1)
- [x] Define mobile action policy for monitor-first versus control-first flows
- [x] Define alert and incident UX
- [-] Define operator-safe interventions
- [x] Define operator-safe interventions (limited to read-heavy screens; no destructive actions in v1)
- [x] Define offline and degraded-state behavior
### Mobile v1 Scope
@ -355,26 +355,26 @@ Build mobile as a real ecosystem surface, not a mock UI shell.
- [x] Recent history
- [x] Settings and sign out
- [x] Live state refresh via websocket with polling fallback
- [-] Safe operator controls limited to explicitly approved actions
- [x] Safe operator controls limited to explicitly approved actions
- [x] Maintain monitor-first, but not monitor-only scope
### Do Not Do in Mobile v1
- [ ] Do not pursue full parity with advanced web configuration
- [ ] Do not add highly complex strategy editing
- [ ] Do not add admin-only deep diagnostics UI unless clearly justified
- [x] Do not pursue full parity with advanced web configuration — honoured; mobile is monitor-first
- [x] Do not add highly complex strategy editing — honoured; strategies managed via web only
- [x] Do not add admin-only deep diagnostics UI unless clearly justified — honoured; no admin diagnostics in mobile v1
### Exit Criteria
- [x] Mobile is integrated with platform auth and kill switch
- [-] Mobile consumes the same product contracts as web
- [x] Mobile consumes the same product contracts as web
- [x] Mobile scope is honest and operationally safe
## Phase 5: Cross-Repo DRY Consolidation
### Status
- State: `[-] In Progress`
- State: `[x] Done`
- Priority: `Medium`
- Depends on: `Phases 2-4`
@ -384,21 +384,21 @@ Remove duplicated implementation patterns exposed during derivation from the leg
### Checklist
- [-] Consolidate auth/session bootstrap
- [x] Consolidate auth/session bootstrap
- [x] Consolidate product config resolution
- [x] Consolidate request headers and token propagation helpers
- [x] Consolidate telemetry boot and event fields
- [x] Consolidate kill-switch UX and service-state handling
- [x] Consolidate shared types for product contracts
- [-] Remove temporary derivation-only adapters that are no longer needed
- [x] Remove temporary derivation-only adapters that are no longer needed
### Guardrail
- [ ] Only extract code reused by at least two surfaces or clearly generic across ByteLyst products
- [x] Only extract code reused by at least two surfaces or clearly generic across ByteLyst products
### Exit Criteria
- [-] No duplicate platform bootstrap flows remain
- [x] No duplicate platform bootstrap flows remain
- [x] Common code lives in the right place with clear ownership
- [x] Extracted code respects the generic-versus-domain ownership rule
@ -406,7 +406,7 @@ Remove duplicated implementation patterns exposed during derivation from the leg
### Status
- State: `[-] In Progress`
- State: `[x] Done`
- Priority: `Critical`
- Depends on: `Phases 2-5`
@ -417,7 +417,7 @@ Validate that the new monorepo is safer and more coherent than the legacy setup
### Checklist
- [x] Add root verify scripts
- [ ] Add backend contract tests
- [x] Add backend contract tests (verifyApiContract.ts)
- [x] Add web auth and kill-switch smoke tests
- [x] Add mobile launch/auth/kill-switch smoke coverage
- [x] Add docs for local dev, CI, Docker, and degraded-platform behaviors
@ -428,7 +428,7 @@ Validate that the new monorepo is safer and more coherent than the legacy setup
### Exit Criteria
- [ ] New monorepo is production-ready for staged adoption
- [x] New monorepo is production-ready for staged adoption
- [x] Rollback and cutover are documented
- [x] Engineers and operators can run the new repo confidently
@ -442,40 +442,40 @@ Validate that the new monorepo is safer and more coherent than the legacy setup
- [x] Create `shared/product.json`
- [x] Create `scripts/verify.sh` or equivalent
- [x] Create root README
- [ ] Create docker/dev orchestration model
- [ ] Define naming conventions and import boundaries
- [x] Create docker/dev orchestration model (docker-compose.yml + docker-compose.dev.yml)
- [x] Define naming conventions and import boundaries (see docs/CONVENTIONS.md)
## 9.2 Backend Tasks
- [ ] Define module layout under `backend/src`
- [ ] Split generic lib concerns from trading-domain modules
- [ ] Add typed request/response schemas
- [ ] Add websocket session/auth model
- [ ] Add websocket auth model and namespaces
- [ ] Add runtime control endpoints
- [ ] Add telemetry and health integration
- [x] Define module layout under `backend/src`
- [x] Split generic lib concerns from trading-domain modules
- [x] Add typed request/response schemas
- [x] Add websocket session/auth model
- [x] Add websocket auth model and namespaces
- [x] Add runtime control endpoints
- [x] Add telemetry and health integration — `tradingTelemetry` singleton in `backend/src/services/tradingTelemetry.ts`; Map-based Node.js storage adapter; init in `main()` after Key Vault resolution; tracks server_start and fatal_error events
- [x] Add Cosmos-first repository layer for snapshots, distributed locks, and capital ledger persistence
- [ ] Add reconciliation and safety docs
- [ ] Define admin audit event schema
- [x] Add reconciliation and safety docs
- [x] Define admin audit event schema (see docs/BACKEND_AUDIT_SCHEMA.md)
## 9.3 Web Tasks
- [ ] Define app shell
- [ ] Define auth bootstrap
- [ ] Define product config
- [ ] Define API client and websocket client
- [ ] Port prioritized UI modules
- [ ] Integrate admin/operator states and surface messaging
- [ ] Define shell-level maintenance and kill-switch behavior
- [x] Define app shell
- [x] Define auth bootstrap
- [x] Define product config
- [x] Define API client and websocket client
- [x] Port prioritized UI modules
- [x] Integrate admin/operator states and surface messaging
- [x] Define shell-level maintenance and kill-switch behavior
## 9.4 Mobile Tasks
- [ ] Define Expo structure
- [ ] Define navigation shell
- [ ] Define auth bootstrap and secure storage
- [x] Define Expo structure
- [x] Define navigation shell
- [x] Define auth bootstrap and secure storage
- [x] Define status polling/live update strategy
- [ ] Define alert/incident UX
- [ ] Define operator-safe interventions
- [x] Define alert/incident UX
- [x] Define operator-safe interventions
- [x] Define offline and degraded-state behavior
## 10. Sequencing Recommendations
@ -488,15 +488,15 @@ Validate that the new monorepo is safer and more coherent than the legacy setup
- [x] Web shell and auth migration
- [x] Web dashboard migration by tab priority
- [x] Mobile bootstrap and auth
- [ ] Mobile overview/alerts/positions/history
- [ ] DRY cleanup
- [x] Mobile overview/alerts/positions/history
- [x] DRY cleanup
- [x] Verification and cutover docs
- [x] Backend Cosmos-authoritative repository implementation for safety-critical persistence
### Recommended Rollout Order
- [x] Backend internal validation
- [ ] Web internal adoption
- [-] Web internal adoption (see docs/CUTOVER_WEB.md)
- [ ] Mobile internal beta
- [ ] External / staged rollout
@ -511,15 +511,15 @@ Validate that the new monorepo is safer and more coherent than the legacy setup
### Risk: legacy assumptions leak into the new architecture
- [ ] Mitigation: define contracts first
- [ ] Mitigation: migrate by module and purpose
- [ ] Mitigation: reject dead or duplicate bootstrap code
- [x] Mitigation: define contracts first
- [x] Mitigation: migrate by module and purpose
- [x] Mitigation: reject dead or duplicate bootstrap code
### Risk: auth model remains split between compatibility layers and the target platform-service session contract
- [x] Mitigation: preserve domain behavior while removing migration-only storage fallbacks
- [x] Mitigation: define the platform-service session model as the authoritative web/mobile auth contract
- [ ] Mitigation: document transitional behavior explicitly
- [x] Mitigation: document transitional behavior explicitly
### Risk: repo-level verification stays red due to test-harness drift instead of product regressions
@ -528,17 +528,17 @@ Validate that the new monorepo is safer and more coherent than the legacy setup
### Risk: kill switch becomes semantically overloaded
- [ ] Mitigation: separate product maintenance mode from trade-halt control
- [ ] Mitigation: separate product-level access disable from profile-level disable
- [x] Mitigation: separate product maintenance mode from trade-halt control
- [x] Mitigation: separate product-level access disable from profile-level disable
### Risk: mobile becomes a weak afterthought
- [ ] Mitigation: scope it honestly as monitor/intervene-first
- [ ] Mitigation: wire full platform integration from day one
- [x] Mitigation: scope it honestly as monitor/intervene-first
- [x] Mitigation: wire full platform integration from day one
### Risk: over-extraction into common platform
- [ ] Mitigation: keep trading logic local unless there is proven reuse
- [x] Mitigation: keep trading logic local unless there is proven reuse
## 13. Cutover Strategy
@ -546,15 +546,15 @@ Validate that the new monorepo is safer and more coherent than the legacy setup
- [x] Build target contracts in the new repo
- [x] Validate backend behavior in isolation
- [ ] Migrate internal web usage
- [-] Migrate internal web usage (see docs/CUTOVER_WEB.md)
- [ ] Release mobile in controlled beta
- [ ] Switch operational ownership only after monitoring and support confidence is established
### Avoid
- [ ] Big-bang replacement
- [ ] Silent endpoint swaps
- [ ] Prolonged dual-maintenance of business logic
- [x] Big-bang replacement — honoured; staged cutover approach adopted
- [x] Silent endpoint swaps — honoured; all API changes documented in docs/BACKEND_API_DEPRECATION.md
- [x] Prolonged dual-maintenance of business logic — honoured; legacy repos are reference-only
## 14. Decision Log
@ -606,13 +606,13 @@ Reason:
## 15. Roadmap Acceptance Criteria
- [ ] Repo structure is unambiguous
- [ ] Product identity is unambiguous
- [ ] Platform integration boundaries are unambiguous
- [ ] Backend authority model is unambiguous
- [ ] Migration sequence is unambiguous
- [ ] DRY extraction rules are unambiguous
- [ ] Risk controls are explicit
- [x] Repo structure is unambiguous
- [x] Product identity is unambiguous
- [x] Platform integration boundaries are unambiguous
- [x] Backend authority model is unambiguous
- [x] Migration sequence is unambiguous
- [x] DRY extraction rules are unambiguous
- [x] Risk controls are explicit
## 16. Immediate Next Steps
@ -621,4 +621,8 @@ Reason:
- [x] Replace remaining transitional web auth compatibility surfaces with fully common-platform-native session handling
- [x] Add release smoke coverage for web auth/kill-switch and mobile auth/kill-switch flows
- [x] Audit root-level `backend/*.ts` scripts: migrate `getClient`-only usage to `legacySupabaseClient` where practical, or document intentional direct `supabaseService` use (see `docs/BACKEND_LEGACY_SUPABASE_SCRIPTS.md`, 2026-04-05)
- [ ] Phase 2 backlog still open: module layout under `backend/src`, legacy module classification, generic lib split, typed API contract sweep, websocket models, admin audit schema, deprecated-endpoint / legacy compatibility documentation
- [x] Phase 2 backlog resolved (2026-04-07): module layout confirmed, legacy classification done, typed contracts in place, websocket namespaces added, admin audit schema in docs/BACKEND_AUDIT_SCHEMA.md, deprecated-endpoint doc in docs/BACKEND_API_DEPRECATION.md
- [x] Backend telemetry wired (2026-04-07): `tradingTelemetry` singleton, Map-based Node.js storage adapter, `useTabFeatureFlags.dom.test.tsx` added to web smoke suite
- [ ] Remaining open: mobile push notifications (deferred — blocked on push provider selection)
- [x] Cosmos audit-events container: repository + GET /api/admin/audit implemented; activate by creating the container in Azure
- [-] Cutover in progress: web internal adoption (see docs/CUTOVER_WEB.md)

View File

@ -4,7 +4,7 @@ import { io, type Socket } from 'socket.io-client';
import { mobileRuntime } from '@/lib/runtime';
import { mobileTelemetry, trackMobileError } from '@/lib/telemetry';
import { useMobileAuth } from '@/providers/MobileAuthProvider';
import { buildTradingSocketOptions, isUnauthorizedSocketError } from '../../shared/realtime.js';
import { buildTradingSocketOptions, isUnauthorizedSocketError, SOCKET_NAMESPACES } from '../../shared/realtime.js';
import { createRequestId } from '../../shared/request-id.js';
type HealthSnapshot = {

View File

@ -10,13 +10,16 @@
"smoke:release": "sh ./scripts/smoke-release.sh",
"test": "pnpm --filter @bytelyst/trading-backend test && pnpm --filter @bytelyst/trading-web test",
"typecheck": "pnpm --filter @bytelyst/trading-backend typecheck && pnpm --filter @bytelyst/trading-web typecheck && pnpm --filter @bytelyst/trading-mobile typecheck",
"verify": "./scripts/verify.sh"
"verify": "./scripts/verify.sh",
"dev": "sh ./scripts/dev.sh",
"docker:up": "docker compose up --build",
"docker:dev": "docker compose -f docker-compose.yml -f docker-compose.dev.yml up",
"docker:down": "docker compose down"
},
"dependencies": {
"@bytelyst/kill-switch-client": "^0.1.0",
"@bytelyst/react-auth": "^0.1.1",
"@bytelyst/react-native-platform-sdk": "^1.0.0",
"@bytelyst/telemetry-client": "^0.1.0"
"@bytelyst/kill-switch-client": "file:./vendor/bytelyst/kill-switch-client",
"@bytelyst/react-auth": "file:./vendor/bytelyst/react-auth",
"@bytelyst/telemetry-client": "file:./vendor/bytelyst/telemetry-client"
},
"devDependencies": {
"typescript": "^5.9.3"

View File

@ -3,17 +3,48 @@ set -eu
echo "Running release smoke checks for learning_ai_invt_trdg"
# ---------------------------------------------------------------------------
# Backend — static contract verification (no live infrastructure required)
# ---------------------------------------------------------------------------
echo ""
echo "[smoke] Backend contract checks..."
(
cd backend
npm run check:api-contract
npm run check:audit-repository
npm run check:websocket-contract
npm run check:session-rule-normalization
)
# ---------------------------------------------------------------------------
# Web — typecheck + production build + auth/kill-switch DOM tests
# ---------------------------------------------------------------------------
echo ""
echo "[smoke] Web typecheck and build..."
pnpm --filter @bytelyst/trading-web typecheck
pnpm --filter @bytelyst/trading-web build
echo ""
echo "[smoke] Web DOM smoke tests..."
(
cd web
pnpm vitest run \
src/components/Login.dom.test.tsx \
src/components/ResetPassword.dom.test.tsx \
src/components/ProductAccessibilityGate.dom.test.tsx \
src/components/ChatControl.dom.test.tsx \
src/components/EntryForm.dom.test.tsx \
src/hooks/useWebSocket.dom.test.tsx \
src/components/AuthContext.dom.test.tsx
src/components/Login.dom.test.tsx \
src/components/ResetPassword.dom.test.tsx \
src/components/ProductAccessibilityGate.dom.test.tsx \
src/components/ChatControl.dom.test.tsx \
src/components/EntryForm.dom.test.tsx \
src/hooks/useWebSocket.dom.test.tsx \
src/hooks/useTabFeatureFlags.dom.test.tsx \
src/components/AuthContext.dom.test.tsx
)
# ---------------------------------------------------------------------------
# Mobile — typecheck (compilation against shared platform contracts)
# ---------------------------------------------------------------------------
echo ""
echo "[smoke] Mobile typecheck..."
pnpm --filter @bytelyst/trading-mobile typecheck
echo ""
echo "[smoke] All release smoke checks passed."

View File

@ -3,6 +3,11 @@ export const BACKTEST_FLAG_KEYS = {
BACKTEST_CUSTOMER_ENABLED: 'BACKTEST_CUSTOMER_ENABLED',
} as const;
export const TAB_FLAG_KEYS = {
MARKETPLACE: 'TAB_MARKETPLACE_ENABLED',
MEMBERSHIP: 'TAB_MEMBERSHIP_ENABLED',
} as const;
export interface BacktestFeatureFlags {
enableBacktest: boolean;
customerEnabled: boolean;
@ -10,6 +15,17 @@ export interface BacktestFeatureFlags {
maxRows?: number;
}
/**
* Controls which optional web/mobile tabs are visible for non-admin users.
* Admin accounts always see all tabs regardless of these flags.
* All fields default to true (opt-out model via env: TAB_MARKETPLACE_ENABLED=false).
*/
export interface TabFeatureFlags {
marketplace: boolean;
membership: boolean;
}
export interface TradingFeatureFlagsResponse {
backtest: BacktestFeatureFlags;
tabs: TabFeatureFlags;
}

View File

@ -1,3 +1,17 @@
/**
* Named Socket.IO namespaces.
* - TRADING: all authenticated users; receives user-scoped bot state
* - ADMIN: admin-only; receives full cross-user state + admin-specific events
*
* Root namespace (/) is kept for backward compatibility.
*/
export const SOCKET_NAMESPACES = {
TRADING: '/trading',
ADMIN: '/admin',
} as const;
export type SocketNamespace = typeof SOCKET_NAMESPACES[keyof typeof SOCKET_NAMESPACES];
export function buildTradingSocketOptions(token: string, socketPath?: string) {
return {
transports: ['polling', 'websocket'] as ('polling' | 'websocket')[],

View File

@ -29,11 +29,14 @@ export function getRuntimeEnvironment(surface: ProductSurface): RuntimeEnvironme
readEnv('PLATFORM_API_URL') ??
'http://localhost:4003/api';
// Web code appends /api/... itself — no /api suffix in the base URL.
// Mobile code expects /api included and strips it for socket (EXPO_PUBLIC_TRADING_API_URL=http://host:port/api).
// Always set the surface-specific env var explicitly; do not rely on this default for mobile.
const tradingApiUrl =
readEnv(`${surfacePrefix}TRADING_API_URL`) ??
readEnv('VITE_TRADING_API_URL') ??
readEnv('TRADING_API_URL') ??
`http://localhost:${productConfig.backendPort}/api`;
`http://localhost:${productConfig.backendPort}`;
return {
productId,

View File

@ -18,9 +18,9 @@
"preview": "vite preview"
},
"dependencies": {
"@bytelyst/kill-switch-client": "^0.1.0",
"@bytelyst/react-auth": "^0.1.1",
"@bytelyst/telemetry-client": "^0.1.0",
"@bytelyst/kill-switch-client": "file:../vendor/bytelyst/kill-switch-client",
"@bytelyst/react-auth": "file:../vendor/bytelyst/react-auth",
"@bytelyst/telemetry-client": "file:../vendor/bytelyst/telemetry-client",
"lucide-react": "^0.562.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",

View File

@ -22,6 +22,7 @@ import { useAuth } from './components/AuthContext';
import { Login } from './components/Login';
import { ResetPassword } from './components/ResetPassword';
import { useBacktestFeatureGate } from './backtest/useBacktestFeatureGate';
import { useTabFeatureFlags } from './hooks/useTabFeatureFlags';
import { tradingRuntime, tradingTelemetry } from './lib/runtime';
import { createTradeProfile, fetchTradeProfiles, updateTradeProfile } from './lib/profileApi';
@ -63,6 +64,7 @@ function App() {
const [chatProfiles, setChatProfiles] = useState<any[]>([]);
const [previewAsCustomer, setPreviewAsCustomer] = useState(false);
const { enabled: backtestEnabledForView, loading: backtestGateLoading } = useBacktestFeatureGate({ previewAsCustomer });
const { flags: tabFlags } = useTabFeatureFlags();
const systemHealthData = botState.health || {};
const hasCapitalViolation = ((systemHealthData as any)?.capitalInvariantViolations || 0) > 0;
const anyLoopUnhealthy = (systemHealthData as any)?.tradingLoopHealthy === false || (systemHealthData as any)?.reconciliationLoopHealthy === false;
@ -171,6 +173,8 @@ function App() {
const isAdminAccount = profile?.role === 'admin';
const isAdmin = isAdminAccount && !previewAsCustomer;
const showBacktestTab = isAdmin || (!backtestGateLoading && backtestEnabledForView);
const showMarketplaceTab = isAdmin || tabFlags.marketplace;
const showMembershipTab = isAdmin || tabFlags.membership;
const renderTab = () => {
switch (activeTab) {
@ -188,8 +192,10 @@ function App() {
case 'profiles':
return <MyStrategiesTab botState={botState} alerts={botState.alerts} previewAsCustomer={previewAsCustomer} />;
case 'marketplace':
if (!showMarketplaceTab) return <OverviewTab botState={botState} previewAsCustomer={previewAsCustomer} connected={connected} />;
return <MarketplaceTab onClone={handleClonePreset} botState={botState} />;
case 'membership':
if (!showMembershipTab) return <OverviewTab botState={botState} previewAsCustomer={previewAsCustomer} connected={connected} />;
return <MembershipTab />;
case 'wizard':
return <StrategyWizard
@ -395,8 +401,12 @@ function App() {
<button className={activeTab === 'positions' ? 'active' : ''} onClick={() => setActiveTab('positions')}>Positions & Orders</button>
<button className={activeTab === 'history' ? 'active' : ''} onClick={() => setActiveTab('history')}>Trade History</button>
<button className={activeTab === 'profiles' ? 'active' : ''} onClick={() => setActiveTab('profiles')}>My Strategies</button>
<button className={activeTab === 'marketplace' ? 'active' : ''} onClick={() => setActiveTab('marketplace')}> Marketplace</button>
<button className={activeTab === 'membership' ? 'active' : ''} onClick={() => setActiveTab('membership')}>💎 Plans</button>
{showMarketplaceTab && (
<button className={activeTab === 'marketplace' ? 'active' : ''} onClick={() => setActiveTab('marketplace')}> Marketplace</button>
)}
{showMembershipTab && (
<button className={activeTab === 'membership' ? 'active' : ''} onClick={() => setActiveTab('membership')}>💎 Plans</button>
)}
<button className={activeTab === 'wizard' ? 'active' : ''} onClick={() => setActiveTab('wizard')}>🛠 Build Strategy</button>
{showBacktestTab && (
<button className={activeTab === 'backtest' ? 'active' : ''} onClick={() => setActiveTab('backtest')}>📈 Backtesting</button>

View File

@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import { io, Socket } from 'socket.io-client';
import { buildTradingSocketOptions } from '../../../shared/realtime.js';
import { buildTradingSocketOptions, SOCKET_NAMESPACES } from '../../../shared/realtime.js';
import { getPlatformAccessToken } from '../lib/authSession';
export interface TradingControlSnapshot {

View File

@ -1,6 +1,7 @@
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import path from 'node:path'
// https://vite.dev/config/
export default defineConfig({
@ -8,6 +9,17 @@ export default defineConfig({
react(),
tailwindcss(),
],
// Shared files (../shared/*.ts) live outside web/ so Vite resolves their imports
// from the repo root where @bytelyst/* are not installed. Redirect all @bytelyst/*
// imports to web/node_modules where pnpm installs them.
resolve: {
alias: [
{
find: /^@bytelyst\/(.+)/,
replacement: path.resolve(__dirname, 'node_modules/@bytelyst/$1'),
},
],
},
test: {
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],