neuroalpha 0.6.13 → 0.7.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.
Files changed (46) hide show
  1. package/README.md +14 -10
  2. package/bin/cli.js +79 -69
  3. package/docs//320/230/320/235/320/241/320/242/320/240/320/243/320/232/320/246/320/230/320/257.md +2 -2
  4. package/package.json +4 -5
  5. package/public/activate.html +41 -19
  6. package/public/activate.js +81 -10
  7. package/public/app.js +471 -166
  8. package/public/index.html +9 -39
  9. package/public/styles.css +42 -3
  10. package/server/api/routes.js +595 -906
  11. package/server/auth/index.js +583 -761
  12. package/server/brokers/index.js +14 -3
  13. package/server/config.js +73 -53
  14. package/server/index.js +186 -212
  15. package/server/llm/local-engine.js +843 -0
  16. package/server/llm/providers.js +45 -34
  17. package/server/model/live.js +2 -1
  18. package/server/notify/index.js +304 -466
  19. package/server/strategist/index.js +4 -1
  20. package/deploy/docs/01-central.md +0 -96
  21. package/deploy/docs/02-cloud-instances.md +0 -130
  22. package/deploy/docs/03-desktop-build.md +0 -92
  23. package/deploy/docs/04-client-guide.md +0 -101
  24. package/deploy/docs/05-operations.md +0 -109
  25. package/deploy/docs/06-env-reference.md +0 -91
  26. package/deploy/docs/07-stress-test.md +0 -253
  27. package/deploy/docs/pdf/01-central.pdf +0 -0
  28. package/deploy/docs/pdf/02-cloud-instances.pdf +0 -0
  29. package/deploy/docs/pdf/03-desktop-build.pdf +0 -0
  30. package/deploy/docs/pdf/04-client-guide.pdf +0 -0
  31. package/deploy/docs/pdf/05-operations.pdf +0 -0
  32. package/deploy/docs/pdf/06-env-reference.pdf +0 -0
  33. package/deploy/docs/pdf/07-stress-test.pdf +0 -0
  34. package/deploy/install.sh +0 -128
  35. package/deploy/k8s/instance.yaml +0 -147
  36. package/public/admin.html +0 -604
  37. package/public/landing.html +0 -1207
  38. package/public/legal.html +0 -134
  39. package/scripts/set-admin-password.mjs +0 -30
  40. package/server/billing/yookassa.js +0 -142
  41. package/server/instance/agent.js +0 -259
  42. package/server/instance/central.js +0 -514
  43. package/server/instance/identity.js +0 -79
  44. package/server/instance/provision.js +0 -166
  45. package/server/instance/signing.js +0 -71
  46. package/server/role.js +0 -44
@@ -1,166 +0,0 @@
1
- // Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
2
- // Автоматическое развёртывание облачных машин клиентов. Драйвер выбирается переменной PAAS_DRIVER:
3
- // k8s — напрямую через API Kubernetes (server-side apply). Внутри кластера берётся токен сервисного аккаунта,
4
- // снаружи — K8S_API_URL + K8S_TOKEN (+ K8S_CA_FILE)
5
- // webhook — POST на PAAS_WEBHOOK_URL с подписью HMAC-SHA256 (заголовок X-NA-Signature) — для собственного PaaS
6
- // none — по умолчанию: машины разворачиваются вручную по шаблону deploy/k8s/instance.yaml
7
- import crypto from 'node:crypto';
8
- import fs from 'node:fs';
9
- import https from 'node:https';
10
- import tls from 'node:tls';
11
-
12
- export const PAAS_DRIVER = String(process.env.PAAS_DRIVER || 'none').trim().toLowerCase();
13
- const IMAGE = String(process.env.INSTANCE_IMAGE || 'registry.odant.org/neuroalpha:latest').trim();
14
- const INGRESS_CLASS = String(process.env.INSTANCE_INGRESS_CLASS || 'nginx').trim();
15
- const TLS_SECRET = String(process.env.INSTANCE_TLS_SECRET || 'odant-wildcard-tls').trim();
16
- const STORAGE = String(process.env.INSTANCE_STORAGE || '5Gi').trim();
17
- const STORAGE_CLASS = String(process.env.INSTANCE_STORAGE_CLASS || '').trim();
18
- const FIELD_MANAGER = 'neuroalpha-central';
19
-
20
- export const provisioningEnabled = () => PAAS_DRIVER === 'k8s' || PAAS_DRIVER === 'webhook';
21
-
22
- // ---------- манифесты машины (то же, что deploy/k8s/instance.yaml) ----------
23
- function manifests(inst, env) {
24
- const ns = inst.id;
25
- const labels = { 'app.kubernetes.io/part-of': 'neuroalpha', 'neuroalpha/instance': inst.id };
26
- const app = { app: 'neuroalpha' };
27
- return [
28
- { apiVersion: 'v1', kind: 'Namespace', metadata: { name: ns, labels } },
29
- { apiVersion: 'v1', kind: 'Secret', metadata: { name: 'neuroalpha-instance', namespace: ns, labels }, type: 'Opaque', stringData: env },
30
- {
31
- apiVersion: 'v1', kind: 'PersistentVolumeClaim', metadata: { name: 'neuroalpha-data', namespace: ns, labels },
32
- spec: { accessModes: ['ReadWriteOnce'], resources: { requests: { storage: STORAGE } }, ...(STORAGE_CLASS ? { storageClassName: STORAGE_CLASS } : {}) },
33
- },
34
- {
35
- apiVersion: 'apps/v1', kind: 'Deployment', metadata: { name: 'neuroalpha', namespace: ns, labels },
36
- spec: {
37
- replicas: 1,
38
- strategy: { type: 'Recreate' },
39
- selector: { matchLabels: app },
40
- template: {
41
- metadata: { labels: { ...app, ...labels }, annotations: { 'neuroalpha/key-rev': crypto.createHash('sha256').update(env.INSTANCE_KEY).digest('hex').slice(0, 12) } },
42
- spec: {
43
- securityContext: { runAsNonRoot: true, runAsUser: 1000, fsGroup: 1000 },
44
- containers: [{
45
- name: 'neuroalpha',
46
- image: IMAGE,
47
- ports: [{ containerPort: 3000 }],
48
- envFrom: [{ secretRef: { name: 'neuroalpha-instance' } }],
49
- volumeMounts: [{ name: 'data', mountPath: '/data' }],
50
- resources: { requests: { cpu: '500m', memory: '1Gi' }, limits: { cpu: '2', memory: '3Gi' } },
51
- readinessProbe: { httpGet: { path: '/healthz', port: 3000 }, initialDelaySeconds: 10, periodSeconds: 15 },
52
- livenessProbe: { httpGet: { path: '/healthz', port: 3000 }, initialDelaySeconds: 60, periodSeconds: 30, failureThreshold: 4 },
53
- securityContext: { allowPrivilegeEscalation: false, capabilities: { drop: ['ALL'] } },
54
- }],
55
- volumes: [{ name: 'data', persistentVolumeClaim: { claimName: 'neuroalpha-data' } }],
56
- },
57
- },
58
- },
59
- },
60
- { apiVersion: 'v1', kind: 'Service', metadata: { name: 'neuroalpha', namespace: ns, labels }, spec: { selector: app, ports: [{ port: 80, targetPort: 3000 }] } },
61
- {
62
- apiVersion: 'networking.k8s.io/v1', kind: 'Ingress',
63
- metadata: { name: 'neuroalpha', namespace: ns, labels, annotations: { 'nginx.ingress.kubernetes.io/proxy-read-timeout': '3600', 'nginx.ingress.kubernetes.io/proxy-send-timeout': '3600' } },
64
- spec: {
65
- ingressClassName: INGRESS_CLASS,
66
- tls: [{ hosts: [inst.host], secretName: TLS_SECRET }],
67
- rules: [{ host: inst.host, http: { paths: [{ path: '/', pathType: 'Prefix', backend: { service: { name: 'neuroalpha', port: { number: 80 } } } }] } }],
68
- },
69
- },
70
- {
71
- apiVersion: 'networking.k8s.io/v1', kind: 'NetworkPolicy', metadata: { name: 'neuroalpha-ingress-only', namespace: ns, labels },
72
- spec: {
73
- podSelector: { matchLabels: app },
74
- policyTypes: ['Ingress'],
75
- ingress: [{ from: [{ namespaceSelector: { matchLabels: { 'kubernetes.io/metadata.name': String(process.env.INSTANCE_INGRESS_NAMESPACE || 'ingress-nginx') } } }], ports: [{ port: 3000 }] }],
76
- },
77
- },
78
- ];
79
- }
80
-
81
- // ---------- драйвер k8s ----------
82
- function k8sConfig() {
83
- const saDir = '/var/run/secrets/kubernetes.io/serviceaccount';
84
- const inCluster = process.env.KUBERNETES_SERVICE_HOST && fs.existsSync(`${saDir}/token`);
85
- const apiUrl = String(process.env.K8S_API_URL || (inCluster ? `https://${process.env.KUBERNETES_SERVICE_HOST}:${process.env.KUBERNETES_SERVICE_PORT || 443}` : '')).replace(/\/+$/, '');
86
- const token = String(process.env.K8S_TOKEN || (inCluster ? fs.readFileSync(`${saDir}/token`, 'utf8') : '')).trim();
87
- const caFile = String(process.env.K8S_CA_FILE || (inCluster ? `${saDir}/ca.crt` : '')).trim();
88
- if (!apiUrl || !token) throw new Error('PAAS_DRIVER=k8s: не заданы K8S_API_URL и K8S_TOKEN (или сервер запущен вне кластера)');
89
- const ca = caFile && fs.existsSync(caFile) ? [...tls.rootCertificates, fs.readFileSync(caFile, 'utf8')] : undefined;
90
- return { apiUrl, token, ca };
91
- }
92
-
93
- const PLURAL = { Namespace: 'namespaces', Secret: 'secrets', PersistentVolumeClaim: 'persistentvolumeclaims', Deployment: 'deployments', Service: 'services', Ingress: 'ingresses', NetworkPolicy: 'networkpolicies' };
94
-
95
- function objectPath(o) {
96
- const base = o.apiVersion === 'v1' ? '/api/v1' : `/apis/${o.apiVersion}`;
97
- const plural = PLURAL[o.kind];
98
- if (o.kind === 'Namespace') return `${base}/namespaces/${o.metadata.name}`;
99
- return `${base}/namespaces/${o.metadata.namespace}/${plural}/${o.metadata.name}`;
100
- }
101
-
102
- function k8sRequest(cfg, method, urlPath, body, contentType = 'application/json') {
103
- return new Promise((resolve, reject) => {
104
- const u = new URL(cfg.apiUrl + urlPath);
105
- const data = body ? Buffer.from(typeof body === 'string' ? body : JSON.stringify(body)) : null;
106
- const req = https.request({
107
- hostname: u.hostname, port: u.port || 443, path: u.pathname + u.search, method, ca: cfg.ca, timeout: 20000,
108
- headers: { Authorization: `Bearer ${cfg.token}`, Accept: 'application/json', ...(data ? { 'Content-Type': contentType, 'Content-Length': data.length } : {}) },
109
- }, (res) => {
110
- let t = '';
111
- res.setEncoding('utf8');
112
- res.on('data', (c) => { t += c; });
113
- res.on('end', () => resolve({ status: res.statusCode, text: t }));
114
- });
115
- req.on('timeout', () => req.destroy(new Error('Kubernetes API: таймаут')));
116
- req.on('error', reject);
117
- if (data) req.write(data);
118
- req.end();
119
- });
120
- }
121
-
122
- async function k8sApply(inst, env) {
123
- const cfg = k8sConfig();
124
- for (const o of manifests(inst, env)) {
125
- const r = await k8sRequest(cfg, 'PATCH', `${objectPath(o)}?fieldManager=${FIELD_MANAGER}&force=true`, o, 'application/apply-patch+yaml');
126
- if (r.status >= 300) throw new Error(`Kubernetes ${o.kind}: HTTP ${r.status} ${r.text.slice(0, 200)}`);
127
- }
128
- }
129
-
130
- async function k8sDelete(inst) {
131
- const cfg = k8sConfig();
132
- const r = await k8sRequest(cfg, 'DELETE', `/api/v1/namespaces/${inst.id}`);
133
- if (r.status >= 300 && r.status !== 404) throw new Error(`Kubernetes Namespace: HTTP ${r.status} ${r.text.slice(0, 200)}`);
134
- }
135
-
136
- // ---------- драйвер webhook (собственный PaaS) ----------
137
- async function webhook(action, inst, env = null) {
138
- const url = String(process.env.PAAS_WEBHOOK_URL || '').trim();
139
- const secret = String(process.env.PAAS_WEBHOOK_SECRET || '').trim();
140
- if (!url || !secret) throw new Error('PAAS_DRIVER=webhook: не заданы PAAS_WEBHOOK_URL и PAAS_WEBHOOK_SECRET');
141
- const body = JSON.stringify({
142
- action, // apply | delete
143
- instanceId: inst.id,
144
- host: inst.host,
145
- image: IMAGE,
146
- env, // переменные для Secret (только при apply)
147
- manifests: env ? manifests(inst, env) : undefined,
148
- ts: Date.now(),
149
- });
150
- const sig = crypto.createHmac('sha256', secret).update(body).digest('hex');
151
- const r = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-NA-Signature': `sha256=${sig}` }, body, signal: AbortSignal.timeout(30000) });
152
- if (!r.ok) throw new Error(`PaaS webhook ${action}: HTTP ${r.status} ${(await r.text().catch(() => '')).slice(0, 200)}`);
153
- }
154
-
155
- // ---------- общий интерфейс ----------
156
- export async function provisionApply(inst, env) {
157
- if (PAAS_DRIVER === 'k8s') return k8sApply(inst, env);
158
- if (PAAS_DRIVER === 'webhook') return webhook('apply', inst, env);
159
- throw new Error('Автоматическое развёртывание выключено (PAAS_DRIVER=none)');
160
- }
161
-
162
- export async function provisionDelete(inst) {
163
- if (PAAS_DRIVER === 'k8s') return k8sDelete(inst);
164
- if (PAAS_DRIVER === 'webhook') return webhook('delete', inst);
165
- return null;
166
- }
@@ -1,71 +0,0 @@
1
- // Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
2
- // Подписанные токены центра (Ed25519): лицензия машины и одноразовый вход (SSO).
3
- // Формат: base64url(JSON-полезная нагрузка) + "." + base64url(подпись)
4
- import crypto from 'node:crypto';
5
- import fs from 'node:fs';
6
- import path from 'node:path';
7
- import { DATA_DIR } from '../config.js';
8
- import { centralPublicKeyCandidates } from './identity.js';
9
-
10
- const ISSUER = 'neuroalpha-central';
11
-
12
- const b64u = (buf) => Buffer.from(buf).toString('base64url');
13
-
14
- // ---------- ключ подписи центра ----------
15
- let privateKey = null;
16
- let publicKeyPem = '';
17
-
18
- function loadSigningKey() {
19
- if (privateKey) return privateKey;
20
- const envPem = String(process.env.LICENSE_SIGNING_KEY || '').trim();
21
- const file = String(process.env.LICENSE_SIGNING_KEY_FILE || '').trim() || path.join(DATA_DIR, '.license-ed25519.pem');
22
- let pem = envPem.includes('BEGIN') ? envPem.replace(/\\n/g, '\n') : '';
23
- if (!pem && fs.existsSync(file)) pem = fs.readFileSync(file, 'utf8');
24
- if (!pem) {
25
- const { privateKey: k } = crypto.generateKeyPairSync('ed25519');
26
- pem = k.export({ type: 'pkcs8', format: 'pem' });
27
- fs.mkdirSync(path.dirname(file), { recursive: true });
28
- fs.writeFileSync(file, pem, { mode: 0o600 });
29
- console.log(`✓ Создан ключ подписи лицензий центра: ${file}`);
30
- }
31
- privateKey = crypto.createPrivateKey(pem);
32
- publicKeyPem = crypto.createPublicKey(privateKey).export({ type: 'spki', format: 'pem' });
33
- return privateKey;
34
- }
35
-
36
- export function centralPublicKeyPem() {
37
- loadSigningKey();
38
- return publicKeyPem;
39
- }
40
-
41
- export function signToken(payload, ttlSec) {
42
- const now = Math.floor(Date.now() / 1000);
43
- const body = { iss: ISSUER, iat: now, exp: now + Math.max(1, Math.round(ttlSec)), ...payload };
44
- const data = b64u(JSON.stringify(body));
45
- const sig = crypto.sign(null, Buffer.from(data), loadSigningKey());
46
- return `${data}.${b64u(sig)}`;
47
- }
48
-
49
- // ---------- проверка на машине клиента (только открытый ключ центра) ----------
50
- function loadVerifyKey() {
51
- const pem = centralPublicKeyCandidates()[0];
52
- return pem ? crypto.createPublicKey(pem) : null;
53
- }
54
-
55
- export function verifyToken(token, { typ, aud, leewaySec = 120 } = {}) {
56
- const key = loadVerifyKey();
57
- if (!key) throw new Error('Не задан открытый ключ центра (LICENSE_PUBLIC_KEY)');
58
- const [data, sig] = String(token || '').split('.');
59
- if (!data || !sig) throw new Error('Некорректный токен');
60
- const ok = crypto.verify(null, Buffer.from(data), key, Buffer.from(sig, 'base64url'));
61
- if (!ok) throw new Error('Подпись токена не прошла проверку');
62
- let p;
63
- try { p = JSON.parse(Buffer.from(data, 'base64url').toString('utf8')); } catch { throw new Error('Некорректный токен'); }
64
- const now = Math.floor(Date.now() / 1000);
65
- if (p.iss !== ISSUER) throw new Error('Токен выпущен не центром НейроАльфа');
66
- if (typ && p.typ !== typ) throw new Error('Неверный тип токена');
67
- if (aud && String(p.aud || '').toLowerCase() !== String(aud).toLowerCase()) throw new Error('Токен выпущен для другой машины');
68
- if (!(p.exp > now - leewaySec)) throw new Error('Срок действия токена истёк');
69
- if (p.iat && p.iat > now + leewaySec) throw new Error('Токен из будущего — проверьте время на сервере');
70
- return p;
71
- }
package/server/role.js DELETED
@@ -1,44 +0,0 @@
1
- // Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
2
- // Режим работы сервера:
3
- // central — центральный сервер платформы: лендинг, кабинеты, оплата, админка, ИИ-прокси, реестр машин клиентов и выпуск лицензий
4
- // instance — машина клиента с лицензией центра. Вид машины (INSTANCE_KIND):
5
- // cloud — арендованная машина в Kubernetes (поддомен naXXXXXX.odant.org), вход через центр
6
- // desktop — установка на компьютер клиента (VIP по договору), активация кодом из кабинета
7
- // dev — локальный запуск разработчика/владельца (прежнее поведение)
8
- import './config.js';
9
- import { BUILD } from './build-info.js';
10
-
11
- // Защищённая сборка для компьютера клиента — всегда instance/desktop, без возможности переопределения
12
- const raw = BUILD ? 'instance' : String(process.env.NEUROALPHA_ROLE || (process.env.CLOUD_MODE === '1' ? 'central' : 'dev')).trim().toLowerCase();
13
- export const ROLE = ['central', 'instance', 'dev'].includes(raw) ? raw : 'dev';
14
- export const IS_CENTRAL = ROLE === 'central';
15
- export const IS_INSTANCE = ROLE === 'instance';
16
- export const IS_DEV = ROLE === 'dev';
17
- export const INSTANCE_KIND = BUILD ? 'desktop' : (IS_INSTANCE && String(process.env.INSTANCE_KIND || 'cloud').trim().toLowerCase() === 'desktop' ? 'desktop' : 'cloud');
18
- export const IS_DESKTOP = IS_INSTANCE && INSTANCE_KIND === 'desktop';
19
-
20
- if (IS_CENTRAL) process.env.CLOUD_MODE = '1';
21
- if (BUILD) {
22
- delete process.env.CLOUD_MODE;
23
- // провайдер ИИ «НейроАльфа Облако» и проверка лицензии ходят только в вшитый центр
24
- process.env.CLOUD_URL = BUILD.centralUrl;
25
- }
26
-
27
- export const INSTANCE_DOMAIN = String(process.env.INSTANCE_DOMAIN || 'odant.org').trim().replace(/^\.+/, '');
28
- export const CENTRAL_URL = (BUILD ? BUILD.centralUrl : String(process.env.CENTRAL_URL || process.env.CLOUD_URL || 'https://neuroalpha.odant.org')).trim().replace(/\/+$/, '');
29
-
30
- // Адрес прослушивания: установка на компьютер по умолчанию доступна только с этого компьютера
31
- export const LISTEN_HOST = String(process.env.HOST || (IS_DESKTOP ? '127.0.0.1' : '')).trim() || undefined;
32
-
33
- // IP клиента: за ingress/прокси (central, cloud-машина или TRUST_PROXY=1) берётся последний адрес из X-Forwarded-For,
34
- // который добавил наш прокси; первые значения цепочки клиент может подделать
35
- export function clientIp(req) {
36
- const behindProxy = IS_CENTRAL || (IS_INSTANCE && !IS_DESKTOP) || process.env.TRUST_PROXY === '1';
37
- const xff = String(req?.headers?.['x-forwarded-for'] || '').split(',').map((s) => s.trim()).filter(Boolean);
38
- if (behindProxy && xff.length) return xff[xff.length - 1];
39
- return String(req?.socket?.remoteAddress || '');
40
- }
41
-
42
- export function isHttpsRequest(req) {
43
- return Boolean(req?.secure || String(req?.headers?.['x-forwarded-proto'] || '').split(',')[0].trim() === 'https');
44
- }