feat(devops): admin-only info, public version, dep checks, role hardening

Backend:
- /api/devops/info now requires admin role (was: any authenticated user).
  Exposes env keys, dep checks, and socket counts — admin-only by design.
- New /api/devops/version (public, no auth) returns build SHA/branch/image
  for ops/CI rollback verification.
- Dep checks: live ping for Cosmos (trading_users) and platform-service.
- Service version read dynamically via readServiceVersion(import.meta.url)
  — no more hardcoded '0.1.0'.
- extra: socketIoConnections + tradingApiUrl for runtime debugging.
- saveCurrentUserProfile no longer accepts client-supplied role —
  prevents drift with platform JWT (which is authoritative).

Web:
- DevOps tab is now admin-only (gated behind isAdmin like Bot Config and
  Admin Panel). Both the section list and content render are guarded.
- Service version baked into bundle via Vite `define` (__WEB_SERVICE_VERSION__)
  read from web/package.json — no more hardcoded VERSION constant.
- Bumps @bytelyst/devops dep to ^0.1.2.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
root 2026-05-10 05:52:48 +00:00
parent 74400fda70
commit 4763a9a9d1
7 changed files with 42 additions and 11 deletions

View File

@ -77,7 +77,7 @@
"socket.io": "^4.8.3",
"winston": "^3.19.0",
"@bytelyst/telemetry-client": "*",
"@bytelyst/devops": "^0.1.1"
"@bytelyst/devops": "^0.1.2"
},
"devDependencies": {
"@types/node": "^25.0.3",

View File

@ -3,7 +3,8 @@ import { createServer } from 'http';
import { Server, Socket } from 'socket.io';
import cors from 'cors';
import { randomUUID } from 'node:crypto';
import { collectDevopsInfo } from '@bytelyst/devops/server';
import { collectDevopsInfo, dependencyCheck, getBuildInfo, httpDependencyCheck, readServiceVersion } from '@bytelyst/devops/server';
import { getContainer } from '@bytelyst/cosmos';
import logger from '../utils/logger.js';
import fs from 'fs';
import path from 'path';
@ -2365,13 +2366,31 @@ export class ApiServer {
res.json(flags);
});
// ── DevOps info: build, runtime, config (auth required) ──────────────
this.app.get('/api/devops/info', this.requireAuth, async (_req, res) => {
// ── DevOps version: PUBLIC, no auth (build SHA + branch + image) ─────
// Useful for ops/CI health checks and rollback verification.
this.app.get('/api/devops/version', (_req, res) => {
res.json(getBuildInfo());
});
// ── DevOps info: full payload (admin-only — exposes env keys, deps) ──
this.app.get('/api/devops/info', this.requireAuth, this.requireAdmin, async (_req, res) => {
try {
const info = await collectDevopsInfo({
productId: config.PRODUCT_ID || 'invttrdg',
serviceName: 'trading-backend',
serviceVersion: process.env.npm_package_version || '0.1.0',
serviceVersion: readServiceVersion(import.meta.url),
dependencyChecks: [
() => dependencyCheck('cosmos', async () => {
// Lightweight ping: read database properties.
const c = getContainer('trading_users');
await c.items.query('SELECT TOP 1 c.id FROM c').fetchNext();
}),
() => httpDependencyCheck('platform-service', `${config.PLATFORM_API_URL}/health`),
],
extra: {
socketIoConnections: this.io?.engine?.clientsCount ?? 0,
tradingApiUrl: config.PLATFORM_API_URL,
},
});
res.json(info);
} catch (error: any) {

View File

@ -719,7 +719,10 @@ export async function saveCurrentUserProfile(
...input,
user_id: userId,
email: String(input.email ?? existing.email ?? fallback.email ?? ''),
role: String(input.role ?? existing.role ?? fallback.role ?? 'member'),
// Role is intentionally NOT persisted from client input — JWT role is the
// authoritative source. We keep the existing stored role for backward
// compatibility but it's overridden on read at the API layer.
role: String(existing.role ?? fallback.role ?? 'member'),
first_name: String(input.first_name ?? existing.first_name ?? fallback.first_name ?? ''),
last_name: String(input.last_name ?? existing.last_name ?? fallback.last_name ?? ''),
trade_enable: Boolean(input.trade_enable ?? existing.trade_enable ?? fallback.trade_enable ?? true),

View File

@ -42,7 +42,7 @@
"react-router-dom": "^7.14.2",
"recharts": "^3.6.0",
"socket.io-client": "^4.8.3",
"@bytelyst/devops": "^0.1.1"
"@bytelyst/devops": "^0.1.2"
},
"devDependencies": {
"@eslint/js": "^9.39.4",

View File

@ -8,7 +8,9 @@ import { DevopsPanel, type DevopsInfo } from '@bytelyst/devops/ui';
import { getPlatformAccessToken } from '../lib/authSession';
import { tradingRuntime } from '../lib/runtime';
const VERSION = '0.1.0';
// Injected by Vite at build time (see web/vite.config.ts → define).
declare const __WEB_SERVICE_VERSION__: string;
const VERSION = typeof __WEB_SERVICE_VERSION__ !== 'undefined' ? __WEB_SERVICE_VERSION__ : '0.0.0';
async function fetchBackendInfo(): Promise<DevopsInfo> {
const token = await getPlatformAccessToken();

View File

@ -17,13 +17,13 @@ export function SettingsView() {
'Account',
...(isAdmin ? ['Bot Config' as SettingsSection] : []),
...(isAdmin ? ['Admin Panel' as SettingsSection] : []),
'DevOps',
...(isAdmin ? ['DevOps' as SettingsSection] : []),
];
const requestedSection = searchParams.get('section');
const initialSection = useMemo<SettingsSection>(() => {
if (requestedSection === 'Bot Config' && isAdmin) return 'Bot Config';
if (requestedSection === 'Admin Panel' && isAdmin) return 'Admin Panel';
if (requestedSection === 'DevOps') return 'DevOps';
if (requestedSection === 'DevOps' && isAdmin) return 'DevOps';
return 'Account';
}, [requestedSection, isAdmin]);
const [section, setSection] = useState<SettingsSection>(initialSection);
@ -64,7 +64,7 @@ export function SettingsView() {
{section === 'Account' && <SettingsTab botState={botState} />}
{section === 'Bot Config' && isAdmin && <ConfigTab />}
{section === 'Admin Panel' && isAdmin && <AdminTab botState={botState} socket={socket} />}
{section === 'DevOps' && <DevOpsTab />}
{section === 'DevOps' && isAdmin && <DevOpsTab />}
</div>
</div>
);

View File

@ -9,6 +9,10 @@ import { storybookTest } from '@storybook/addon-vitest/vitest-plugin';
import { playwright } from '@vitest/browser-playwright';
const dirname = typeof __dirname !== 'undefined' ? __dirname : path.dirname(fileURLToPath(import.meta.url));
// Read service version from package.json so DevOps panel reflects the real version.
const webPackageJson = JSON.parse(fs.readFileSync(path.resolve(__dirname, 'package.json'), 'utf8')) as { version?: string };
const webServiceVersion = webPackageJson.version || '0.0.0';
// More info at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon
const monacoEditorPath = path.resolve(__dirname, 'node_modules/monaco-editor');
const workspaceRoot = path.resolve(__dirname, '..', '..');
@ -32,6 +36,9 @@ function bytelystAlias(pkg: string): string {
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
define: {
__WEB_SERVICE_VERSION__: JSON.stringify(webServiceVersion),
},
// 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 first to web/node_modules, then fall back to the monorepo vendor/ dir.