@bhooai/nexus-cli 0.1.5 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +26 -27
- package/src/commands/add.ts +274 -0
- package/src/commands/build.ts +91 -0
- package/src/commands/db.ts +241 -0
- package/src/commands/dev.ts +149 -186
- package/src/commands/doctor.ts +102 -184
- package/src/commands/init.ts +204 -958
- package/src/commands/maintenance-commands.ts +40 -0
- package/src/commands/plugin.ts +233 -0
- package/src/commands/queue.ts +145 -0
- package/src/commands/test.ts +34 -0
- package/src/dispatcher.ts +98 -0
- package/src/generators.ts +102 -0
- package/src/index.ts +32 -90
- package/src/make/generators.ts +431 -0
- package/src/ports.ts +85 -0
- package/src/prompts.ts +68 -0
- package/src/templating/render.ts +109 -0
- package/src/util.ts +74 -112
- package/templates/base/Dockerfile.ejs +68 -0
- package/templates/base/README.md.ejs +62 -0
- package/templates/base/apps/admin/index.html.ejs +12 -0
- package/templates/base/apps/admin/nginx.conf.ejs +16 -0
- package/templates/base/apps/admin/package.json.ejs +15 -0
- package/templates/base/apps/admin/src/main.tsx.ejs +7 -0
- package/templates/base/apps/admin/vite.config.ts.ejs +22 -0
- package/templates/base/apps/ai-server/main.py.ejs +30 -0
- package/templates/base/apps/ai-server/requirements.txt +4 -0
- package/templates/base/apps/backend/src/main.ts +14 -0
- package/templates/base/apps/backend/src/routes/index.ts.ejs +11 -0
- package/templates/base/apps/frontend/index.html.ejs +12 -0
- package/templates/base/apps/frontend/nginx.conf.ejs +33 -0
- package/templates/base/apps/frontend/package.json.ejs +11 -0
- package/templates/base/apps/frontend/src/main.tsx.ejs +16 -0
- package/templates/base/apps/frontend/vite.config.ts.ejs +22 -0
- package/templates/base/docker-compose.yml.ejs +93 -0
- package/templates/base/dockerignore +21 -0
- package/templates/base/gitignore +43 -0
- package/templates/base/nexus.config.ts.ejs +97 -0
- package/templates/base/package.json.ejs +40 -0
- package/templates/{tsconfig.json → base/tsconfig.json} +2 -3
- package/tsconfig.json +5 -4
- package/PLAN.md +0 -141
- package/README.md +0 -34
- package/src/commands/cluster.ts +0 -133
- package/src/commands/node.ts +0 -106
- package/src/commands/pysetup.ts +0 -142
- package/src/commands/sync.ts +0 -116
- package/src/commands/uninstall.ts +0 -287
- package/src/config-sync.ts +0 -384
- package/src/dotenv.ts +0 -39
- package/src/supervisor.ts +0 -394
- package/src/wizard.ts +0 -149
- package/templates/Dockerfile +0 -60
- package/templates/README.md +0 -69
- package/templates/apps/admin/index.html +0 -12
- package/templates/apps/admin/package.json +0 -22
- package/templates/apps/admin/postcss.config.js +0 -3
- package/templates/apps/admin/src/main.tsx +0 -10
- package/templates/apps/admin/src/vite-env.d.ts +0 -18
- package/templates/apps/admin/tailwind.config.js +0 -9
- package/templates/apps/admin/tsconfig.json +0 -17
- package/templates/apps/admin/vite.config.ts +0 -64
- package/templates/apps/ai-server/main.py +0 -43
- package/templates/apps/ai-server/providers/__init__.py +0 -3
- package/templates/apps/ai-server/providers/base.py +0 -111
- package/templates/apps/ai-server/requirements.txt +0 -3
- package/templates/apps/ai-server/routers/__init__.py +0 -3
- package/templates/apps/ai-server/routers/chat.py +0 -47
- package/templates/apps/ai-server/routers/embeddings.py +0 -30
- package/templates/apps/ai-server/routers/lint.py +0 -167
- package/templates/apps/ai-server/routers/models.py +0 -23
- package/templates/apps/ai-server/routers/preflight.py +0 -169
- package/templates/apps/ai-server/settings.py +0 -48
- package/templates/apps/backend/package.json +0 -34
- package/templates/apps/backend/src/main.ts +0 -375
- package/templates/apps/backend/src/modules/admin/adminRoutes.ts +0 -732
- package/templates/apps/backend/src/modules/admin/clusterRoutes.ts +0 -391
- package/templates/apps/backend/src/modules/admin/databaseRoutes.ts +0 -161
- package/templates/apps/backend/src/modules/admin/lintProxy.ts +0 -89
- package/templates/apps/backend/src/modules/admin/preflightProxy.ts +0 -242
- package/templates/apps/backend/src/modules/admin/roleCatalog.ts +0 -78
- package/templates/apps/backend/src/modules/admin/schemaRoutes.ts +0 -449
- package/templates/apps/backend/src/modules/ai/aiProxy.ts +0 -265
- package/templates/apps/backend/src/modules/auth/authRoutes.ts +0 -220
- package/templates/apps/backend/src/modules/payments/paymentRoutes.ts +0 -100
- package/templates/apps/backend/src/modules/payments/paymentStore.ts +0 -172
- package/templates/apps/backend/src/modules/requests/requestLog.ts +0 -175
- package/templates/apps/backend/src/modules/users/userGraph.ts +0 -83
- package/templates/apps/backend/src/modules/users/userModel.ts +0 -88
- package/templates/apps/backend/src/plugins/CronScheduler.ts +0 -69
- package/templates/apps/backend/src/plugins/loadPlugins.ts +0 -107
- package/templates/apps/backend/tsconfig.json +0 -14
- package/templates/apps/frontend/index.html +0 -12
- package/templates/apps/frontend/package.json +0 -20
- package/templates/apps/frontend/postcss.config.js +0 -3
- package/templates/apps/frontend/src/index.css +0 -44
- package/templates/apps/frontend/src/main.tsx +0 -65
- package/templates/apps/frontend/tailwind.config.js +0 -9
- package/templates/apps/frontend/vite.config.ts +0 -72
- package/templates/bin/nexus.js +0 -35
- package/templates/bin/serve-all.mjs +0 -45
- package/templates/dockerignore +0 -15
- package/templates/gitignore +0 -12
- package/templates/nexus.config.ts +0 -69
- package/templates/package.json +0 -47
- package/tests/cli.test.ts +0 -52
- package/tests/config-sync.test.ts +0 -201
- package/tests/dotenv.test.ts +0 -51
- package/vitest.config.ts +0 -9
- package/vitest.config.ts.timestamp-1786095205351-444061f6ff5c58.mjs +0 -13
- /package/templates/{uploads → base/apps/backend/storage}/.gitkeep +0 -0
|
@@ -1,169 +0,0 @@
|
|
|
1
|
-
"""Preflight diagnostics router — connectivity + latency checks.
|
|
2
|
-
|
|
3
|
-
The Node backend composes the targets it wants checked (backend health, AI
|
|
4
|
-
server health, Mongo/Redis TCP reachability, service ports) and posts them here.
|
|
5
|
-
Python performs the checks concurrently and returns a normalized report. Only
|
|
6
|
-
stdlib (`socket`) + httpx are used, so this router adds no new dependencies.
|
|
7
|
-
|
|
8
|
-
Failed checks carry an `errorCategory` (refused/timeout/dns/ssl/http/other) plus
|
|
9
|
-
a short human-readable `error` message so the admin console never has to surface
|
|
10
|
-
raw transport exceptions like httpx's "All connection attempts failed...".
|
|
11
|
-
"""
|
|
12
|
-
from __future__ import annotations
|
|
13
|
-
|
|
14
|
-
import asyncio
|
|
15
|
-
import socket
|
|
16
|
-
import ssl
|
|
17
|
-
import time
|
|
18
|
-
from typing import Any, Literal
|
|
19
|
-
from urllib.parse import urlparse
|
|
20
|
-
|
|
21
|
-
import httpx
|
|
22
|
-
from fastapi import APIRouter, HTTPException
|
|
23
|
-
from pydantic import BaseModel
|
|
24
|
-
|
|
25
|
-
router = APIRouter()
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
class Target(BaseModel):
|
|
29
|
-
name: str
|
|
30
|
-
kind: Literal["http", "tcp"] = "tcp"
|
|
31
|
-
url: str | None = None
|
|
32
|
-
host: str | None = None
|
|
33
|
-
port: int | None = None
|
|
34
|
-
timeout: float | None = None
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
class PreflightRequest(BaseModel):
|
|
38
|
-
targets: list[Target] = []
|
|
39
|
-
timeout: float = 2.0
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def _classify_error(e: Exception, host: str = "", port: int | None = None) -> tuple[str, str]:
|
|
43
|
-
"""Map an exception to a (category, friendly message) pair.
|
|
44
|
-
|
|
45
|
-
httpx wraps low-level socket errors, so when the top-level exception is the
|
|
46
|
-
generic "All connection attempts failed..." the underlying cause is unwrapped.
|
|
47
|
-
"""
|
|
48
|
-
endpoint = f"{host}:{port}" if host else "target"
|
|
49
|
-
|
|
50
|
-
if isinstance(e, (httpx.ConnectTimeout, httpx.ConnectError, asyncio.TimeoutError, socket.timeout)):
|
|
51
|
-
try:
|
|
52
|
-
cause = e.__cause__
|
|
53
|
-
except AttributeError:
|
|
54
|
-
cause = None
|
|
55
|
-
if isinstance(cause, socket.gaierror):
|
|
56
|
-
return "dns", f"host not found: {host or 'unknown'}"
|
|
57
|
-
if isinstance(cause, (ConnectionRefusedError, OSError)) and getattr(cause, "errno", None) == getattr(socket, "ECONNREFUSED", 111):
|
|
58
|
-
return "refused", f"connection refused on {endpoint} — is the service running?"
|
|
59
|
-
return "timeout", "timed out — the service may be filtering traffic or is down"
|
|
60
|
-
|
|
61
|
-
if isinstance(e, socket.gaierror):
|
|
62
|
-
return "dns", f"host not found: {host or 'unknown'}"
|
|
63
|
-
if isinstance(e, (ConnectionRefusedError, OSError)) and getattr(e, "errno", None) == getattr(socket, "ECONNREFUSED", 111):
|
|
64
|
-
return "refused", f"connection refused on {endpoint} — is the service running?"
|
|
65
|
-
if isinstance(e, ssl.SSLError):
|
|
66
|
-
return "ssl", "TLS handshake failed"
|
|
67
|
-
return "other", str(e) or type(e).__name__
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
async def _check_http(target: Target, timeout: float) -> dict[str, Any]:
|
|
71
|
-
url = target.url or ""
|
|
72
|
-
start = time.perf_counter()
|
|
73
|
-
try:
|
|
74
|
-
async with httpx.AsyncClient(timeout=httpx.Timeout(timeout, connect=1.0, read=timeout, write=timeout, pool=timeout), follow_redirects=True) as client:
|
|
75
|
-
resp = await client.get(url)
|
|
76
|
-
latency_ms = round((time.perf_counter() - start) * 1000, 1)
|
|
77
|
-
ok = resp.status_code < 400
|
|
78
|
-
category: str | None = None
|
|
79
|
-
error: str | None = None
|
|
80
|
-
if not ok:
|
|
81
|
-
category = "http"
|
|
82
|
-
error = f"HTTP {resp.status_code}"
|
|
83
|
-
return {
|
|
84
|
-
"name": target.name,
|
|
85
|
-
"kind": "http",
|
|
86
|
-
"url": url,
|
|
87
|
-
"ok": ok,
|
|
88
|
-
"status": resp.status_code,
|
|
89
|
-
"latencyMs": latency_ms,
|
|
90
|
-
"errorCategory": category,
|
|
91
|
-
"error": error,
|
|
92
|
-
}
|
|
93
|
-
except Exception as e: # noqa: BLE001 - classify connectivity errors
|
|
94
|
-
latency_ms = round((time.perf_counter() - start) * 1000, 1)
|
|
95
|
-
category, message = _classify_error(e, urlparse(url).hostname or "", urlparse(url).port)
|
|
96
|
-
return {
|
|
97
|
-
"name": target.name,
|
|
98
|
-
"kind": "http",
|
|
99
|
-
"url": url,
|
|
100
|
-
"ok": False,
|
|
101
|
-
"status": None,
|
|
102
|
-
"latencyMs": latency_ms,
|
|
103
|
-
"errorCategory": category,
|
|
104
|
-
"error": message,
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
async def _check_tcp(target: Target, timeout: float) -> dict[str, Any]:
|
|
109
|
-
host = target.host or ""
|
|
110
|
-
port = target.port or 0
|
|
111
|
-
start = time.perf_counter()
|
|
112
|
-
try:
|
|
113
|
-
reader, writer = await asyncio.wait_for(
|
|
114
|
-
asyncio.open_connection(host, port, limit=256),
|
|
115
|
-
timeout,
|
|
116
|
-
)
|
|
117
|
-
latency_ms = round((time.perf_counter() - start) * 1000, 1)
|
|
118
|
-
writer.close()
|
|
119
|
-
try:
|
|
120
|
-
await writer.wait_closed()
|
|
121
|
-
except Exception: # noqa: BLE001
|
|
122
|
-
pass
|
|
123
|
-
return {
|
|
124
|
-
"name": target.name,
|
|
125
|
-
"kind": "tcp",
|
|
126
|
-
"host": host,
|
|
127
|
-
"port": port,
|
|
128
|
-
"ok": True,
|
|
129
|
-
"latencyMs": latency_ms,
|
|
130
|
-
"errorCategory": None,
|
|
131
|
-
"error": None,
|
|
132
|
-
}
|
|
133
|
-
except Exception as e: # noqa: BLE001 - classify connectivity errors
|
|
134
|
-
latency_ms = round((time.perf_counter() - start) * 1000, 1)
|
|
135
|
-
category, message = _classify_error(e, host, port)
|
|
136
|
-
return {
|
|
137
|
-
"name": target.name,
|
|
138
|
-
"kind": "tcp",
|
|
139
|
-
"host": host,
|
|
140
|
-
"port": port,
|
|
141
|
-
"ok": False,
|
|
142
|
-
"latencyMs": latency_ms,
|
|
143
|
-
"errorCategory": category,
|
|
144
|
-
"error": message,
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
@router.post("/preflight")
|
|
149
|
-
async def preflight(req: PreflightRequest):
|
|
150
|
-
if not req.targets:
|
|
151
|
-
raise HTTPException(status_code=400, detail="at least one target is required")
|
|
152
|
-
started = time.perf_counter()
|
|
153
|
-
results = await asyncio.gather(*(_check_http(t, t.timeout or req.timeout) if t.kind == "http" else _check_tcp(t, t.timeout or req.timeout) for t in req.targets))
|
|
154
|
-
duration_ms = round((time.perf_counter() - started) * 1000, 1)
|
|
155
|
-
|
|
156
|
-
failed = [c for c in results if not c["ok"]]
|
|
157
|
-
passed = [c for c in results if c["ok"]]
|
|
158
|
-
# failed then warnings (slow) then the rest, preserving stable order within groups.
|
|
159
|
-
slow = [c for c in passed if isinstance(c.get("latencyMs"), (int, float)) and c["latencyMs"] > 800]
|
|
160
|
-
ok = [c for c in passed if c not in slow]
|
|
161
|
-
|
|
162
|
-
return {
|
|
163
|
-
"ranAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
164
|
-
"durationMs": duration_ms,
|
|
165
|
-
"passed": len(passed),
|
|
166
|
-
"warnings": len(slow),
|
|
167
|
-
"failed": len(failed),
|
|
168
|
-
"checks": failed + slow + ok,
|
|
169
|
-
}
|
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
"""Configuration for the AI server (env-driven)."""
|
|
2
|
-
from __future__ import annotations
|
|
3
|
-
|
|
4
|
-
import os
|
|
5
|
-
from dataclasses import dataclass, field
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
def _env(key: str, default: str) -> str:
|
|
9
|
-
return os.environ.get(key, default)
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
@dataclass
|
|
13
|
-
class Settings:
|
|
14
|
-
# OpenAI-compatible upstreams. Both OpenAI and Ollama speak the same shape;
|
|
15
|
-
# only the base URL + auth header differ.
|
|
16
|
-
openai_base_url: str = field(default_factory=lambda: _env("AI_OPENAI_BASE_URL", "https://api.openai.com/v1"))
|
|
17
|
-
# Fall back to the generic OPENAI_API_KEY / OLLAMA_HOST names so project
|
|
18
|
-
# .env keys work with the AI server as well as the AI_* names.
|
|
19
|
-
openai_api_key: str = field(
|
|
20
|
-
default_factory=lambda: _env("AI_OPENAI_API_KEY", _env("OPENAI_API_KEY", ""))
|
|
21
|
-
)
|
|
22
|
-
ollama_base_url: str = field(
|
|
23
|
-
default_factory=lambda: _env(
|
|
24
|
-
"AI_OLLAMA_BASE_URL",
|
|
25
|
-
(_env("OLLAMA_HOST", "http://localhost:11434") + "/v1"),
|
|
26
|
-
)
|
|
27
|
-
)
|
|
28
|
-
ollama_api_key: str = field(default_factory=lambda: _env("AI_OLLAMA_API_KEY", ""))
|
|
29
|
-
|
|
30
|
-
default_provider: str = field(default_factory=lambda: _env("AI_DEFAULT_PROVIDER", "auto"))
|
|
31
|
-
request_timeout_s: float = field(default_factory=lambda: float(_env("AI_TIMEOUT_S", "120")))
|
|
32
|
-
server_port: int = field(default_factory=lambda: int(_env("AI_PORT", "8000")))
|
|
33
|
-
|
|
34
|
-
# CORS: the Node backend proxies, but allow dev tools + the admin to call directly.
|
|
35
|
-
cors_origins: list[str] = field(default_factory=lambda: _env("AI_CORS_ORIGINS", "*").split(","))
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
settings = Settings()
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
def resolve_provider(requested: str | None) -> str:
|
|
42
|
-
"""Resolve 'auto' to a concrete provider. auto → openai if a key is set, else ollama."""
|
|
43
|
-
provider = (requested or settings.default_provider or "auto").lower()
|
|
44
|
-
if provider == "auto":
|
|
45
|
-
provider = "openai" if settings.openai_api_key else "ollama"
|
|
46
|
-
if provider not in ("openai", "ollama"):
|
|
47
|
-
raise ValueError(f"unknown provider: {provider}")
|
|
48
|
-
return provider
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@bhooai/app-backend",
|
|
3
|
-
"version": "0.1.0",
|
|
4
|
-
"private": true,
|
|
5
|
-
"type": "module",
|
|
6
|
-
"main": "./src/main.ts",
|
|
7
|
-
"scripts": {
|
|
8
|
-
"dev": "tsx watch src/main.ts",
|
|
9
|
-
"start": "tsx src/main.ts",
|
|
10
|
-
"test": "vitest run"
|
|
11
|
-
},
|
|
12
|
-
"dependencies": {
|
|
13
|
-
"@bhooai/nexus-core": "*",
|
|
14
|
-
"@bhooai/nexus-auth": "*",
|
|
15
|
-
"@bhooai/nexus-telemetry": "*",
|
|
16
|
-
"@bhooai/nexus-data": "*",
|
|
17
|
-
"@bhooai/nexus-realtime": "*",
|
|
18
|
-
"@bhooai/nexus-graphql": "*",
|
|
19
|
-
"@bhooai/nexus-cache": "*",
|
|
20
|
-
"@bhooai/nexus-email": "*",
|
|
21
|
-
"@bhooai/nexus-payments": "*",
|
|
22
|
-
"@bhooai/nexus-crypto": "*",
|
|
23
|
-
"@bhooai/nexus-ads": "*",
|
|
24
|
-
"@bhooai/nexus-plugins": "*",
|
|
25
|
-
"@bhooai/nexus-ai-client": "*",
|
|
26
|
-
"@bhooai/nexus-cluster": "*"
|
|
27
|
-
},
|
|
28
|
-
"devDependencies": {
|
|
29
|
-
"@types/node": "^22.5.0",
|
|
30
|
-
"tsx": "^4.19.0",
|
|
31
|
-
"typescript": "^5.6.2",
|
|
32
|
-
"vitest": "^2.1.1"
|
|
33
|
-
}
|
|
34
|
-
}
|
|
@@ -1,375 +0,0 @@
|
|
|
1
|
-
import { dirname, join, resolve } from 'node:path';
|
|
2
|
-
import { readFile } from 'node:fs/promises';
|
|
3
|
-
import { fileURLToPath } from 'node:url';
|
|
4
|
-
import { loadConfig, loadConfigAuto, Router, NexusServer, bodyParser, serveStatic, registerUploadRoutes } from '@bhooai/nexus-core';
|
|
5
|
-
import { cors, csrf, securityHeaders, rateLimit, issueCsrfToken, AuthService, MemorySessionStore } from '@bhooai/nexus-auth';
|
|
6
|
-
import { Logger, MetricsRegistry } from '@bhooai/nexus-telemetry';
|
|
7
|
-
import {
|
|
8
|
-
connect,
|
|
9
|
-
getConnection,
|
|
10
|
-
resolveProjectInfo,
|
|
11
|
-
connectProjectInfo,
|
|
12
|
-
getProjectInfo,
|
|
13
|
-
listProjectInfo,
|
|
14
|
-
upsertProjectInfo,
|
|
15
|
-
closeProjectInfo,
|
|
16
|
-
type ProjectInfo,
|
|
17
|
-
} from '@bhooai/nexus-data';
|
|
18
|
-
import { RealtimeServer } from '@bhooai/nexus-realtime';
|
|
19
|
-
import { createGateway, graphqlHttpHandler, SubscriptionServer, PubSub, type GraphQLContext } from '@bhooai/nexus-graphql';
|
|
20
|
-
import { Cache } from '@bhooai/nexus-cache';
|
|
21
|
-
import { createEmail, TemplateEngine } from '@bhooai/nexus-email';
|
|
22
|
-
import { createPayments } from '@bhooai/nexus-payments';
|
|
23
|
-
import { generateKeyPair, createSelfSignedCertificate, createCsr } from '@bhooai/nexus-crypto';
|
|
24
|
-
import { ClusterManager } from '@bhooai/nexus-cluster';
|
|
25
|
-
import { registerAuthRoutes } from './modules/auth/authRoutes.js';
|
|
26
|
-
import { buildUsersSubgraph } from './modules/users/userGraph.js';
|
|
27
|
-
import { loadPlugins } from './plugins/loadPlugins.js';
|
|
28
|
-
import { registerAiRoutes } from './modules/ai/aiProxy.js';
|
|
29
|
-
import { registerAdminRoutes } from './modules/admin/adminRoutes.js';
|
|
30
|
-
import { registerPaymentRoutes } from './modules/payments/paymentRoutes.js';
|
|
31
|
-
import { recordTransaction } from './modules/payments/paymentStore.js';
|
|
32
|
-
import { appendRequestLog, closeRequestLog } from './modules/requests/requestLog.js';
|
|
33
|
-
|
|
34
|
-
// Keep project-relative config and runtime directories tied to this app tree,
|
|
35
|
-
// not to the directory from which the backend command was launched.
|
|
36
|
-
const PROJECT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* BhooAI Nexus backend bootstrap (Phase 5 checkpoint).
|
|
40
|
-
*
|
|
41
|
-
* Boots an inbuilt HTTP server with the security stack applied:
|
|
42
|
-
* security headers → CORS → body parsing → CSRF → rate limiting.
|
|
43
|
-
* Connects to MongoDB, registers the User model, mounts /auth/* routes,
|
|
44
|
-
* and serves a single-subgraph GraphQL gateway over HTTP (/graphql) and
|
|
45
|
-
* WebSocket subscriptions (/graphql/ws). Realtime (/ws) runs alongside.
|
|
46
|
-
*/
|
|
47
|
-
async function main(): Promise<void> {
|
|
48
|
-
const config = await loadConfigAuto({ root: PROJECT_ROOT });
|
|
49
|
-
const log = new Logger({
|
|
50
|
-
level: config.logging.level,
|
|
51
|
-
format: config.logging.format,
|
|
52
|
-
console: config.logging.console,
|
|
53
|
-
redact: ['password', 'secret', 'authorization'],
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
// Each project owns its own database (named from its package.json). The
|
|
57
|
-
// default connection targets that database; a second connection targets the
|
|
58
|
-
// shared project-info store (nexus_projects) for name + settings.
|
|
59
|
-
const project: ProjectInfo = await resolveProjectInfo(PROJECT_ROOT);
|
|
60
|
-
const dbName = config.db.name ?? project.dbName;
|
|
61
|
-
|
|
62
|
-
connect(config.db.uri, { name: dbName, autoIndex: config.db.autoIndex });
|
|
63
|
-
await getConnection().db;
|
|
64
|
-
connectProjectInfo(config.db.uri, { autoIndex: config.db.autoIndex });
|
|
65
|
-
const allProjects = await listProjectInfo();
|
|
66
|
-
// Prefer the record matched by path so a project renamed via the admin panel
|
|
67
|
-
// keeps its identity (and settings) across restarts.
|
|
68
|
-
const storedProject = allProjects.find((p) => p.path === PROJECT_ROOT) ?? (await getProjectInfo(project.name).catch(() => null));
|
|
69
|
-
const configuredAiProviders = (config.ai.providers ?? []).map((provider) => ({
|
|
70
|
-
id: provider.id,
|
|
71
|
-
label: provider.label,
|
|
72
|
-
baseUrl: provider.baseUrl,
|
|
73
|
-
enabled: provider.enabled,
|
|
74
|
-
...(provider.defaultModel ? { defaultModel: provider.defaultModel } : {}),
|
|
75
|
-
}));
|
|
76
|
-
await upsertProjectInfo({
|
|
77
|
-
...project,
|
|
78
|
-
dbName,
|
|
79
|
-
status: 'running',
|
|
80
|
-
startedAt: new Date().toISOString(),
|
|
81
|
-
version: storedProject?.version ?? await readPackageVersion(PROJECT_ROOT),
|
|
82
|
-
settings: {
|
|
83
|
-
...(storedProject?.settings ?? {}),
|
|
84
|
-
env: config.env,
|
|
85
|
-
host: config.server.host,
|
|
86
|
-
port: config.server.port,
|
|
87
|
-
graphqlPath: config.graphql.path,
|
|
88
|
-
websocketPath: config.ws.path,
|
|
89
|
-
database: dbName,
|
|
90
|
-
...(!storedProject?.settings?.aiProviders && configuredAiProviders.length > 0
|
|
91
|
-
? { aiProviders: configuredAiProviders }
|
|
92
|
-
: {}),
|
|
93
|
-
},
|
|
94
|
-
});
|
|
95
|
-
log.info('connected to MongoDB', { project: project.name, db: dbName });
|
|
96
|
-
|
|
97
|
-
// One AuthService shared by realtime + GraphQL context (token verification
|
|
98
|
-
// is stateless; the session store is only used for login/refresh flows).
|
|
99
|
-
const authService = new AuthService(
|
|
100
|
-
{ secret: config.auth.jwt.secret, algorithm: 'HS256', issuer: config.auth.jwt.issuer, audience: config.auth.jwt.audience, accessTtl: config.auth.jwt.accessTtl, refreshTtl: config.auth.jwt.refreshTtl },
|
|
101
|
-
new MemorySessionStore(),
|
|
102
|
-
);
|
|
103
|
-
|
|
104
|
-
const router = new Router();
|
|
105
|
-
const uploadsDir = join(PROJECT_ROOT, config.uploads.dir);
|
|
106
|
-
|
|
107
|
-
// Multipart uploads are persisted beneath the project-local /uploads folder.
|
|
108
|
-
// Filenames are generated by the framework; the public path is read-only.
|
|
109
|
-
registerUploadRoutes(router, {
|
|
110
|
-
directory: uploadsDir,
|
|
111
|
-
path: config.uploads.path,
|
|
112
|
-
publicPath: config.uploads.path,
|
|
113
|
-
maxFileSize: config.uploads.maxFileSize,
|
|
114
|
-
maxFiles: config.uploads.maxFiles,
|
|
115
|
-
allowedTypes: config.uploads.allowedTypes,
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
// Health check (no CSRF, no rate limit — always available).
|
|
119
|
-
router.get('/health', (ctx) => {
|
|
120
|
-
ctx.json({ status: 'ok', env: config.env, time: Date.now() });
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
// CSRF token endpoint — mints a double-submit token cookie + returns it.
|
|
124
|
-
router.get('/csrf-token', (ctx) => {
|
|
125
|
-
const token = issueCsrfToken(ctx, { trustedOrigins: trustedOrigins(config) });
|
|
126
|
-
ctx.json({ token });
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
// A sample unsafe route to demonstrate CSRF enforcement.
|
|
130
|
-
router.post('/echo', (ctx) => {
|
|
131
|
-
ctx.json({ received: ctx.body });
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
// Auth routes (register/login/refresh/logout/me + OAuth). Requires DB.
|
|
135
|
-
registerAuthRoutes(router, config, () => pubsub.publish('USER_COUNT', 1));
|
|
136
|
-
|
|
137
|
-
// Create a mutable copy of the AI providers array so the admin provider
|
|
138
|
-
// management endpoints can mutate API keys / enabled state in-place, and
|
|
139
|
-
// the AI proxy sees the updates without restarting. The config is frozen
|
|
140
|
-
// (Object.freeze) so we must clone before passing to both routes.
|
|
141
|
-
const aiProviders = (config.ai.providers ?? []).map((p) => ({ ...p }));
|
|
142
|
-
|
|
143
|
-
// AI proxy — mounts /ai/* OpenAI-compatible routes that re-emit SSE from the
|
|
144
|
-
// Python AI server. The browser never calls Python directly.
|
|
145
|
-
registerAiRoutes(router, {
|
|
146
|
-
serverUrl: config.ai.serverUrl,
|
|
147
|
-
timeoutMs: config.ai.timeoutMs,
|
|
148
|
-
// Pass the mutable array — same reference the admin endpoints mutate.
|
|
149
|
-
providers: aiProviders,
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
// GraphQL: single-subgraph gateway over the users subgraph. The context
|
|
153
|
-
// builder verifies the optional Bearer access token so `me` can resolve.
|
|
154
|
-
const usersSubgraph = buildUsersSubgraph();
|
|
155
|
-
const gateway = createGateway({ subgraph: usersSubgraph });
|
|
156
|
-
// PubSub feeds the GraphQL `userCount` subscription (published on register).
|
|
157
|
-
const pubsub = new PubSub();
|
|
158
|
-
const graphContext = async (ctx: import('@bhooai/nexus-core').RequestContext): Promise<GraphQLContext> => {
|
|
159
|
-
const auth = ctx.headers['authorization'];
|
|
160
|
-
const header = Array.isArray(auth) ? auth[0] : auth;
|
|
161
|
-
const token = header && header.startsWith('Bearer ') ? header.slice(7) : undefined;
|
|
162
|
-
if (token) {
|
|
163
|
-
try {
|
|
164
|
-
const claims = await authService.verifyAccessToken(token);
|
|
165
|
-
return { request: ctx, user: { sub: claims.sub, roles: claims.roles ?? [], sid: claims.sid }, pubsub };
|
|
166
|
-
} catch { /* invalid token → anonymous */ }
|
|
167
|
-
}
|
|
168
|
-
return { request: ctx, pubsub };
|
|
169
|
-
};
|
|
170
|
-
router.post(config.graphql.path, graphqlHttpHandler({ gateway, introspection: config.graphql.introspection, context: graphContext }));
|
|
171
|
-
router.get(config.graphql.path, graphqlHttpHandler({ gateway, introspection: config.graphql.introspection, context: graphContext }));
|
|
172
|
-
|
|
173
|
-
// Phase 7 services — cache (lazy Redis), email (log provider in dev), payments
|
|
174
|
-
// webhook router (no providers enabled unless keys are configured), and a
|
|
175
|
-
// cert/CSR generation endpoint backed by nexus-crypto (hand-rolled DER for CSRs).
|
|
176
|
-
const cache = new Cache({ url: config.redis.url, keyPrefix: config.redis.keyPrefix });
|
|
177
|
-
|
|
178
|
-
const templates = new TemplateEngine();
|
|
179
|
-
templates.register('welcome', '<h1>Welcome, {{name}}!</h1><p>Verify your email to get started.</p>');
|
|
180
|
-
const email = createEmail(config.email, { templates });
|
|
181
|
-
|
|
182
|
-
const payments = createPayments(config.payments);
|
|
183
|
-
const webhookRouter = payments.webhookRouter((ev) => {
|
|
184
|
-
log.info('payment webhook event', { provider: ev.provider, event: ev.event, verified: ev.verified });
|
|
185
|
-
// Persist every verified webhook event as a transaction (admin dashboard).
|
|
186
|
-
if (ev.provider && ev.event) {
|
|
187
|
-
recordTransaction(ev.provider, ev).catch((err) => log.warn('failed to record payment transaction', { msg: err.message }));
|
|
188
|
-
}
|
|
189
|
-
});
|
|
190
|
-
if (config.payments.webhookPath) {
|
|
191
|
-
router.post(config.payments.webhookPath, webhookRouter.handler);
|
|
192
|
-
}
|
|
193
|
-
// Phase 11 — browser-facing checkout routes (auth-guarded). Webhooks stay separate.
|
|
194
|
-
registerPaymentRoutes(router, config, payments, authService);
|
|
195
|
-
|
|
196
|
-
// Generate a self-signed cert + keypair (and optionally a CSR) on demand.
|
|
197
|
-
router.post('/certs/self-signed', (ctx) => {
|
|
198
|
-
const body = (ctx.body ?? {}) as { commonName?: string; organization?: string; country?: string; csr?: boolean };
|
|
199
|
-
const kp = generateKeyPair(config.certs.keyType, {
|
|
200
|
-
modulusLength: config.certs.rsaModulus,
|
|
201
|
-
namedCurve: config.certs.ecCurve,
|
|
202
|
-
});
|
|
203
|
-
const cert = createSelfSignedCertificate({
|
|
204
|
-
keyPair: { publicKey: kp.publicKey, privateKey: kp.privateKey },
|
|
205
|
-
commonName: body.commonName ?? 'localhost',
|
|
206
|
-
organization: body.organization,
|
|
207
|
-
country: body.country,
|
|
208
|
-
});
|
|
209
|
-
const out: Record<string, string> = { cert: cert.pem, privateKey: kp.pem.private, publicKey: kp.pem.public };
|
|
210
|
-
if (body.csr) {
|
|
211
|
-
const csr = createCsr({
|
|
212
|
-
keyPair: { publicKey: kp.publicKey, privateKey: kp.privateKey },
|
|
213
|
-
commonName: body.commonName ?? 'localhost',
|
|
214
|
-
organization: body.organization,
|
|
215
|
-
country: body.country,
|
|
216
|
-
});
|
|
217
|
-
out.csr = csr.pem;
|
|
218
|
-
}
|
|
219
|
-
ctx.json(out);
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
const docsDir = join(PROJECT_ROOT, 'docs');
|
|
223
|
-
|
|
224
|
-
const server = new NexusServer({
|
|
225
|
-
router,
|
|
226
|
-
bodyLimit: config.server.bodyLimit,
|
|
227
|
-
middleware: [
|
|
228
|
-
securityHeaders(),
|
|
229
|
-
cors({ origin: true, credentials: true }),
|
|
230
|
-
serveStatic(uploadsDir, { prefix: config.uploads.path }),
|
|
231
|
-
serveStatic(docsDir, { prefix: '/docs' }),
|
|
232
|
-
bodyParser(config.server.bodyLimit),
|
|
233
|
-
csrf({ trustedOrigins: trustedOrigins(config) }),
|
|
234
|
-
rateLimit({ windowMs: 60_000, max: 300 }),
|
|
235
|
-
],
|
|
236
|
-
onError: (err, ctx) => {
|
|
237
|
-
log.warn('request error', { code: err.code, path: ctx.path, method: ctx.method, msg: err.message });
|
|
238
|
-
return { error: { code: err.code, message: err.message } };
|
|
239
|
-
},
|
|
240
|
-
});
|
|
241
|
-
|
|
242
|
-
// Realtime (WebSocket) server: auth via access token on upgrade, optional
|
|
243
|
-
// CSRF origin/double-submit check. Reuses the shared authService.
|
|
244
|
-
const realtime = new RealtimeServer({
|
|
245
|
-
httpServer: server.httpServer,
|
|
246
|
-
path: config.ws.path,
|
|
247
|
-
authService,
|
|
248
|
-
csrfOptions: config.ws.requireCsrf ? { trustedOrigins: trustedOrigins(config) } : undefined,
|
|
249
|
-
});
|
|
250
|
-
|
|
251
|
-
// GraphQL subscriptions over WebSocket (graphql-transport-ws), on a path
|
|
252
|
-
// under the GraphQL route. Auth via the shared authService.
|
|
253
|
-
const subscriptions = new SubscriptionServer({
|
|
254
|
-
httpServer: server.httpServer,
|
|
255
|
-
gateway,
|
|
256
|
-
path: `${config.graphql.path}/ws`,
|
|
257
|
-
authService,
|
|
258
|
-
context: (_init, user) => ({ user, pubsub }),
|
|
259
|
-
});
|
|
260
|
-
|
|
261
|
-
// Phase 8 — plugins: build real HostBindings from the running services,
|
|
262
|
-
// discover plugins from the configured dir, and run their lifecycle in
|
|
263
|
-
// dependency order before the server accepts traffic. Exposes the admin
|
|
264
|
-
// extensions (pages/slots contributed by plugins) at /plugins/extensions.
|
|
265
|
-
const pluginsDir = join(PROJECT_ROOT, config.plugins.dir);
|
|
266
|
-
const configByPlugin: Record<string, unknown> = {};
|
|
267
|
-
for (const entry of config.plugins.entries) {
|
|
268
|
-
if (entry.enabled) configByPlugin[entry.path] = entry.config ?? {};
|
|
269
|
-
}
|
|
270
|
-
const plugins = await loadPlugins({
|
|
271
|
-
router,
|
|
272
|
-
log,
|
|
273
|
-
realtime,
|
|
274
|
-
pluginsDir,
|
|
275
|
-
configByPlugin,
|
|
276
|
-
});
|
|
277
|
-
router.get('/plugins/extensions', (ctx) => {
|
|
278
|
-
ctx.json(plugins.adminExtensions.toJSON());
|
|
279
|
-
});
|
|
280
|
-
|
|
281
|
-
// Admin endpoints — bearer-token auth + 'admin' role (first registered user
|
|
282
|
-
// is bootstrapped admin). Edits write to nexus.runtime.json (gitignored).
|
|
283
|
-
const metrics = new MetricsRegistry();
|
|
284
|
-
metrics.counter('http_requests_total', 'Total HTTP requests');
|
|
285
|
-
const requestLogDir = join(PROJECT_ROOT, config.logging.dir);
|
|
286
|
-
// Count every HTTP request so the admin overview "HTTP REQUESTS" stat reflects
|
|
287
|
-
// live traffic, and append each completed request (with IP, url, referer etc.)
|
|
288
|
-
// to a datewise JSON log file under the project's logging dir.
|
|
289
|
-
server.use((ctx, next) => {
|
|
290
|
-
metrics.inc('http_requests_total', 1, { method: ctx.method });
|
|
291
|
-
const started = Date.now();
|
|
292
|
-
ctx.res.on('finish', () => {
|
|
293
|
-
const headers = ctx.headers as Record<string, string | string[] | undefined>;
|
|
294
|
-
const first = (v: string | string[] | undefined): string | undefined => (Array.isArray(v) ? v[0] : v);
|
|
295
|
-
const forwarded = first(headers['x-forwarded-for'])?.split(',')[0]?.trim();
|
|
296
|
-
appendRequestLog(requestLogDir, {
|
|
297
|
-
time: started,
|
|
298
|
-
method: ctx.method,
|
|
299
|
-
path: ctx.path,
|
|
300
|
-
url: ctx.req.url,
|
|
301
|
-
status: ctx.res.statusCode,
|
|
302
|
-
durationMs: Date.now() - started,
|
|
303
|
-
ip: forwarded || ctx.req.socket.remoteAddress,
|
|
304
|
-
referer: first(headers['referer']),
|
|
305
|
-
userAgent: first(headers['user-agent']),
|
|
306
|
-
origin: first(headers['origin']),
|
|
307
|
-
requestId: ctx.requestId,
|
|
308
|
-
route: ctx.routePattern,
|
|
309
|
-
});
|
|
310
|
-
});
|
|
311
|
-
return next();
|
|
312
|
-
});
|
|
313
|
-
|
|
314
|
-
// Cluster manager — auto-starts the LB + autoscaler when config says enabled.
|
|
315
|
-
const cluster = new ClusterManager({ config: config.cluster, root: PROJECT_ROOT, aiServerUrl: config.ai.serverUrl, self: { id: 'master', baseUrl: `http://127.0.0.1:${config.server.port}` } });
|
|
316
|
-
if (config.cluster.enabled) {
|
|
317
|
-
try {
|
|
318
|
-
await cluster.listenLb(config.cluster.lbHost);
|
|
319
|
-
cluster.autoscaler.start(10_000);
|
|
320
|
-
log.info(`cluster LB listening on ${config.cluster.lbHost}:${config.cluster.lbPort} (auto-started)`);
|
|
321
|
-
} catch (err) {
|
|
322
|
-
log.warn(`cluster failed to auto-start: ${(err as Error).message}`);
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
registerAdminRoutes(router, config, { root: PROJECT_ROOT, project, adminExtensions: plugins.adminExtensions, metrics: () => metrics.toJSON(), payments, cluster, aiProviders });
|
|
327
|
-
|
|
328
|
-
await server.listen(config.server.port, config.server.host);
|
|
329
|
-
log.info(`Nexus backend listening on http://${config.server.host}:${config.server.port}`, {
|
|
330
|
-
graphql: config.graphql.path,
|
|
331
|
-
ws: config.ws.path,
|
|
332
|
-
});
|
|
333
|
-
|
|
334
|
-
const shutdown = async (signal: string) => {
|
|
335
|
-
log.info(`received ${signal}, shutting down`);
|
|
336
|
-
await plugins.host.stop();
|
|
337
|
-
plugins.scheduler.close();
|
|
338
|
-
await subscriptions.close();
|
|
339
|
-
await realtime.close();
|
|
340
|
-
await server.close();
|
|
341
|
-
await cluster.close();
|
|
342
|
-
await closeProjectInfo();
|
|
343
|
-
await getConnection().close();
|
|
344
|
-
await cache.close();
|
|
345
|
-
closeRequestLog();
|
|
346
|
-
process.exit(0);
|
|
347
|
-
};
|
|
348
|
-
process.on('SIGINT', () => void shutdown('SIGINT'));
|
|
349
|
-
process.on('SIGTERM', () => void shutdown('SIGTERM'));
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
function trustedOrigins(config: Awaited<ReturnType<typeof loadConfig>>): string[] {
|
|
353
|
-
const loopback = ['localhost', '127.0.0.1'];
|
|
354
|
-
const origins: string[] = [];
|
|
355
|
-
for (const host of loopback) {
|
|
356
|
-
for (const port of [config.server.port, config.frontend.port, config.admin.port]) {
|
|
357
|
-
origins.push(`http://${host}:${port}`);
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
return origins;
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
async function readPackageVersion(root: string): Promise<string | undefined> {
|
|
364
|
-
try {
|
|
365
|
-
const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')) as { version?: string };
|
|
366
|
-
return typeof pkg.version === 'string' ? pkg.version : undefined;
|
|
367
|
-
} catch {
|
|
368
|
-
return undefined;
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
main().catch((err) => {
|
|
373
|
-
console.error(err);
|
|
374
|
-
process.exit(1);
|
|
375
|
-
});
|