@danieltmn/openbridge 0.1.0

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 (44) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/LICENSE +21 -0
  3. package/README.md +143 -0
  4. package/bin/openbridge.js +18 -0
  5. package/docs/ARCHITECTURE.md +77 -0
  6. package/package.json +54 -0
  7. package/src/auth.js +204 -0
  8. package/src/bridge/bridge.js +2280 -0
  9. package/src/cli.js +791 -0
  10. package/src/config.js +108 -0
  11. package/src/log.js +22 -0
  12. package/src/paths.js +152 -0
  13. package/src/push.js +85 -0
  14. package/src/store/index.js +930 -0
  15. package/src/store/jsonfile.js +54 -0
  16. package/src/tunnel/index.js +141 -0
  17. package/src/web/assets/app.js +3939 -0
  18. package/src/web/assets/icons/icon-128x128.png +0 -0
  19. package/src/web/assets/icons/icon-144x144.png +0 -0
  20. package/src/web/assets/icons/icon-152x152.png +0 -0
  21. package/src/web/assets/icons/icon-192x192.png +0 -0
  22. package/src/web/assets/icons/icon-384x384.png +0 -0
  23. package/src/web/assets/icons/icon-512x512.png +0 -0
  24. package/src/web/assets/icons/icon-72x72.png +0 -0
  25. package/src/web/assets/icons/icon-96x96.png +0 -0
  26. package/src/web/assets/manifest.webmanifest +24 -0
  27. package/src/web/assets/sw.js +85 -0
  28. package/src/web/assets/themes/dark-plus.css +19 -0
  29. package/src/web/assets/themes/dark-red.css +18 -0
  30. package/src/web/assets/themes/default.css +18 -0
  31. package/src/web/assets/themes/github-light.css +19 -0
  32. package/src/web/assets/themes/high-contrast.css +18 -0
  33. package/src/web/assets/themes/index.json +15 -0
  34. package/src/web/assets/themes/light-plus.css +19 -0
  35. package/src/web/assets/themes/light.css +18 -0
  36. package/src/web/assets/themes/monokai.css +19 -0
  37. package/src/web/assets/themes/one-dark.css +19 -0
  38. package/src/web/assets/themes/solarized.css +18 -0
  39. package/src/web/assets/themes/terminal.css +28 -0
  40. package/src/web/assets/themes/themes.css +2 -0
  41. package/src/web/routes.js +871 -0
  42. package/src/web/server.js +130 -0
  43. package/src/web/templates/chat.html +1296 -0
  44. package/src/web/templates/login.html +144 -0
package/src/config.js ADDED
@@ -0,0 +1,108 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const crypto = require('node:crypto');
5
+ const paths = require('./paths');
6
+
7
+ const DEFAULT_BRIDGE = {
8
+ apiUrl: '',
9
+ apiUrlLocal: '',
10
+ mode: 'remoto',
11
+ apiToken: '',
12
+ pollIntervalMs: 1500,
13
+ command: 'opencode',
14
+ opencodeTimeoutMs: 15 * 60 * 1000,
15
+ logFile: 'logs/bridge.log',
16
+ foldersFile: 'folders.json',
17
+ workspace: '',
18
+ allowCreateFolders: true,
19
+ models: [],
20
+ agents: ['build', 'plan'],
21
+ bridgeId: '',
22
+ bridgeName: '',
23
+ processes: { enabled: true, allow: ['npm', 'node', 'npx'], maxGlobal: 3 },
24
+ };
25
+
26
+ const DEFAULT_APP = {
27
+ port: 8799,
28
+ host: '127.0.0.1',
29
+ baseUrl: '',
30
+ username: 'admin',
31
+ password: null, // { algo, salt, hash, keylen }
32
+ csrfSecret: '',
33
+ bridgeToken: '',
34
+ vapid: { publicKey: '', privateKey: '' },
35
+ tunnel: { provider: 'tunnelmole', domain: '' },
36
+ };
37
+
38
+ function readJsonFile(file, fallback) {
39
+ try {
40
+ const raw = fs.readFileSync(file, 'utf8');
41
+ const data = JSON.parse(raw);
42
+ return (data && typeof data === 'object') ? data : { ...fallback };
43
+ } catch (e) {
44
+ return { ...fallback };
45
+ }
46
+ }
47
+
48
+ function writeJsonFile(file, data) {
49
+ paths.ensureDirs();
50
+ const tmp = file + '.' + process.pid + '.tmp';
51
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2));
52
+ fs.renameSync(tmp, file);
53
+ }
54
+
55
+ function readBridge() {
56
+ return { ...DEFAULT_BRIDGE, ...readJsonFile(paths.configPath(), DEFAULT_BRIDGE) };
57
+ }
58
+ function writeBridge(cfg) {
59
+ writeJsonFile(paths.configPath(), cfg);
60
+ }
61
+ function readApp() {
62
+ const data = { ...DEFAULT_APP, ...readJsonFile(paths.appConfigPath(), DEFAULT_APP) };
63
+ data.vapid = { ...DEFAULT_APP.vapid, ...(data.vapid || {}) };
64
+ data.tunnel = { ...DEFAULT_APP.tunnel, ...(data.tunnel || {}) };
65
+ return data;
66
+ }
67
+ function writeApp(cfg) {
68
+ writeJsonFile(paths.appConfigPath(), cfg);
69
+ }
70
+
71
+ function randomToken(bytes = 32) {
72
+ return crypto.randomBytes(bytes).toString('base64url');
73
+ }
74
+
75
+ function hashPassword(password) {
76
+ const salt = crypto.randomBytes(16).toString('hex');
77
+ const keylen = 64;
78
+ const hash = crypto.scryptSync(String(password), salt, keylen).toString('hex');
79
+ return { algo: 'scrypt', salt, hash, keylen };
80
+ }
81
+
82
+ function verifyPassword(password, stored) {
83
+ if (!stored || stored.algo !== 'scrypt' || !stored.salt || !stored.hash) return false;
84
+ const keylen = parseInt(stored.keylen, 10) || 64;
85
+ let calc;
86
+ try {
87
+ calc = crypto.scryptSync(String(password), stored.salt, keylen);
88
+ } catch (e) {
89
+ return false;
90
+ }
91
+ const expected = Buffer.from(stored.hash, 'hex');
92
+ if (calc.length !== expected.length) return false;
93
+ return crypto.timingSafeEqual(calc, expected);
94
+ }
95
+
96
+ // Claves VAPID (P-256) en el formato que espera web-push (base64url).
97
+ function genVapid() {
98
+ const webpush = require('web-push');
99
+ const keys = webpush.generateVAPIDKeys();
100
+ return { publicKey: keys.publicKey, privateKey: keys.privateKey };
101
+ }
102
+
103
+ module.exports = {
104
+ DEFAULT_BRIDGE, DEFAULT_APP,
105
+ readBridge, writeBridge, readApp, writeApp,
106
+ randomToken, hashPassword, verifyPassword, genVapid,
107
+ readJsonFile, writeJsonFile,
108
+ };
package/src/log.js ADDED
@@ -0,0 +1,22 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const paths = require('./paths');
5
+
6
+ function ts() {
7
+ return new Date().toISOString();
8
+ }
9
+
10
+ function write(line) {
11
+ const text = '[' + ts() + '] ' + line;
12
+ try {
13
+ fs.appendFileSync(paths.serverLogPath(), text + '\n');
14
+ } catch (e) { /* sin log no detenemos nada */ }
15
+ console.log(text);
16
+ }
17
+
18
+ function log(...parts) { write(parts.join(' ')); }
19
+ function error(...parts) { write('ERROR: ' + parts.join(' ')); }
20
+ function warn(...parts) { write('aviso: ' + parts.join(' ')); }
21
+
22
+ module.exports = { log, error, warn };
package/src/paths.js ADDED
@@ -0,0 +1,152 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Casa portable de OpenBridge.
5
+ *
6
+ * La "base" es el directorio que elegis (cwd, --dir o OPENBRIDGE_HOME). Todos
7
+ * los archivos de OpenBridge viven dentro de `<base>/.openbridge`:
8
+ *
9
+ * config.json → configuracion del puente (la lee src/bridge/bridge.js)
10
+ * app.json → configuracion de la app (password, token, VAPID, puerto)
11
+ * folders.json → lista blanca de carpetas
12
+ * data/ → sesiones, mensajes, catalogos, registro de puentes, push
13
+ * logs/ → logs del server y del puente
14
+ *
15
+ * Si existe un layout viejo (config.json suelto en la base), migrate() lo mueve
16
+ * automaticamente a `.openbridge/`.
17
+ */
18
+
19
+ const fs = require('node:fs');
20
+ const path = require('node:path');
21
+
22
+ const DIR_NAME = '.openbridge';
23
+
24
+ let overrideBase = null;
25
+
26
+ function setBase(dir) {
27
+ overrideBase = dir ? path.resolve(dir) : null;
28
+ }
29
+ // Alias historico: la CLI y los tests llaman setHome(dir) con la base.
30
+ function setHome(dir) { setBase(dir); }
31
+
32
+ function baseDir() {
33
+ if (overrideBase) return overrideBase;
34
+ const env = process.env.OPENBRIDGE_HOME || process.env.OPENCONEX_HOME;
35
+ if (env) return path.resolve(env);
36
+ return process.cwd();
37
+ }
38
+
39
+ function home() {
40
+ const base = baseDir();
41
+ // Evita anidar `.openbridge/.openbridge` si la base ya ES la carpeta de datos.
42
+ if (path.basename(base) === DIR_NAME) return base;
43
+ return path.join(base, DIR_NAME);
44
+ }
45
+
46
+ function p(...parts) {
47
+ return path.join(home(), ...parts);
48
+ }
49
+
50
+ function configPath() { return p('config.json'); }
51
+ function appConfigPath() { return p('app.json'); }
52
+ function foldersPath() { return p('folders.json'); }
53
+ function modePath() { return p('mode.txt'); }
54
+ function pidPath() { return p('.openbridge.pid'); }
55
+ function bridgeLockPath() { return p('.bridge.pid'); }
56
+ function dataDir() { return p('data'); }
57
+ function logsDir() { return p('logs'); }
58
+ function serverLogPath() { return p('logs', 'server.log'); }
59
+ function bridgeLogPath() { return p('logs', 'bridge.log'); }
60
+ function syncStatePath() { return p('sync-state.json'); }
61
+ function procsStatePath() { return p('.procs.json'); }
62
+
63
+ function sessionsFile() { return p('data', 'sessions.json'); }
64
+ function catalogFile() { return p('data', 'catalog.json'); }
65
+ function bridgesFile() { return p('data', 'bridges.json'); }
66
+ function pushFile() { return p('data', 'push.json'); }
67
+ function messagesFile(id) { return p('data', 'messages-' + (parseInt(id, 10) || 0) + '.json'); }
68
+ function bridgeCatalogFile(id) {
69
+ if (!id || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,39}$/.test(id)) return catalogFile();
70
+ return p('data', 'catalog-' + id.replace(/[^A-Za-z0-9._-]/g, '') + '.json');
71
+ }
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // Migracion del layout viejo (archivos sueltos en la base) a `.openbridge/`
75
+ // ---------------------------------------------------------------------------
76
+ const LEGACY_FILES = [
77
+ 'config.json', 'app.json', 'folders.json', 'mode.txt',
78
+ 'sync-state.json', '.procs.json', '.openbridge.pid', '.bridge.pid', 'runtime.json',
79
+ ];
80
+
81
+ function legacyDataDir(dir) {
82
+ return fs.existsSync(path.join(dir, 'sessions.json'))
83
+ || fs.existsSync(path.join(dir, 'catalog.json'))
84
+ || fs.existsSync(path.join(dir, 'bridges.json'));
85
+ }
86
+ function legacyLogsDir(dir) {
87
+ return fs.existsSync(path.join(dir, 'bridge.log'))
88
+ || fs.existsSync(path.join(dir, 'server.log'));
89
+ }
90
+
91
+ function movePath(from, to) {
92
+ try {
93
+ fs.renameSync(from, to);
94
+ return true;
95
+ } catch (e) {
96
+ try {
97
+ fs.cpSync(from, to, { recursive: true });
98
+ fs.rmSync(from, { recursive: true, force: true });
99
+ return true;
100
+ } catch (e2) {
101
+ return false;
102
+ }
103
+ }
104
+ }
105
+
106
+ // Mueve el layout viejo a `.openbridge/`. Devuelve los nombres migrados (array;
107
+ // vacio si no habia nada que migrar). Solo toca data/ y logs/ si tienen marcas
108
+ // de OpenBridge, para no mover carpetas ajenas del usuario.
109
+ function migrate() {
110
+ const base = baseDir();
111
+ const target = home();
112
+ if (fs.existsSync(target)) return [];
113
+ if (!fs.existsSync(path.join(base, 'config.json'))) return [];
114
+
115
+ const items = [];
116
+ for (const n of LEGACY_FILES) {
117
+ if (fs.existsSync(path.join(base, n))) items.push(n);
118
+ }
119
+ const dataSrc = path.join(base, 'data');
120
+ if (fs.existsSync(dataSrc) && legacyDataDir(dataSrc)) items.push('data');
121
+ const logsSrc = path.join(base, 'logs');
122
+ if (fs.existsSync(logsSrc) && legacyLogsDir(logsSrc)) items.push('logs');
123
+ if (!items.length) return [];
124
+
125
+ try { fs.mkdirSync(target, { recursive: true }); } catch (e) { return []; }
126
+ const moved = [];
127
+ for (const n of items) {
128
+ if (movePath(path.join(base, n), path.join(target, n))) moved.push(n);
129
+ }
130
+ return moved;
131
+ }
132
+
133
+ function ensureDirs() {
134
+ migrate();
135
+ for (const d of [home(), dataDir(), logsDir()]) {
136
+ try { fs.mkdirSync(d, { recursive: true }); } catch (e) { /* ya existe */ }
137
+ }
138
+ }
139
+
140
+ function exists() {
141
+ return fs.existsSync(configPath()) && fs.existsSync(appConfigPath());
142
+ }
143
+
144
+ module.exports = {
145
+ DIR_NAME,
146
+ setBase, setHome, baseDir, home, p,
147
+ configPath, appConfigPath, foldersPath, modePath, pidPath, bridgeLockPath,
148
+ dataDir, logsDir, serverLogPath, bridgeLogPath,
149
+ syncStatePath, procsStatePath,
150
+ sessionsFile, catalogFile, bridgesFile, pushFile, messagesFile, bridgeCatalogFile,
151
+ migrate, ensureDirs, exists,
152
+ };
package/src/push.js ADDED
@@ -0,0 +1,85 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Avisos Web Push (VAPID + cifrado RFC 8291/8188). Usa la libreria `web-push`
5
+ * para el envio; las suscripciones viven en data/push.json (mismo esquema que
6
+ * la version PHP: endpoint + p256dh + auth).
7
+ */
8
+
9
+ const paths = require('./paths');
10
+ const jsonfile = require('./store/jsonfile');
11
+
12
+ const PUSH_DEFAULT = () => ({ subscriptions: [] });
13
+
14
+ function pushEnabled(app) {
15
+ return !!(app && app.vapid && app.vapid.publicKey && app.vapid.privateKey);
16
+ }
17
+ function publicKeyBase64url(app) {
18
+ return pushEnabled(app) ? app.vapid.publicKey : '';
19
+ }
20
+
21
+ async function pushRead() {
22
+ const data = await jsonfile.readJson(paths.pushFile(), PUSH_DEFAULT());
23
+ if (!Array.isArray(data.subscriptions)) data.subscriptions = [];
24
+ return data;
25
+ }
26
+ async function pushStore(endpoint, p256dh, auth, ua = '') {
27
+ await jsonfile.update(paths.pushFile(), PUSH_DEFAULT(), (data) => {
28
+ if (!Array.isArray(data.subscriptions)) data.subscriptions = [];
29
+ const found = data.subscriptions.find((s) => s.endpoint === endpoint);
30
+ if (found) {
31
+ found.p256dh = p256dh;
32
+ found.auth = auth;
33
+ found.ua = ua;
34
+ found.ts = new Date().toISOString();
35
+ } else {
36
+ data.subscriptions.push({ endpoint, p256dh, auth, ua, ts: new Date().toISOString() });
37
+ }
38
+ });
39
+ }
40
+ async function pushRemove(endpoint) {
41
+ await jsonfile.update(paths.pushFile(), PUSH_DEFAULT(), (data) => {
42
+ data.subscriptions = (data.subscriptions || []).filter((s) => s.endpoint !== endpoint);
43
+ });
44
+ }
45
+
46
+ // Envia el aviso a todas las suscripciones. Devuelve cuantas se pudieron enviar.
47
+ async function pushSend(app, title, body, clickPath = 'chat.php') {
48
+ if (!pushEnabled(app)) return 0;
49
+ let webpush;
50
+ try { webpush = require('web-push'); } catch (e) { return 0; }
51
+ const url = (app.baseUrl || 'http://localhost') + '/' + String(clickPath || 'chat.php');
52
+ const payload = JSON.stringify({ title, body, url, ts: new Date().toISOString() });
53
+ if (Buffer.byteLength(payload) > 3500) return 0;
54
+ try {
55
+ webpush.setVapidDetails(app.baseUrl || 'http://localhost', app.vapid.publicKey, app.vapid.privateKey);
56
+ } catch (e) {
57
+ return 0;
58
+ }
59
+ const data = await pushRead();
60
+ let sent = 0;
61
+ const dead = [];
62
+ for (const sub of data.subscriptions) {
63
+ const endpoint = sub.endpoint || '';
64
+ if (!/^https:\/\//.test(endpoint)) continue;
65
+ try {
66
+ await webpush.sendNotification(
67
+ { endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
68
+ payload,
69
+ { TTL: 86400 }
70
+ );
71
+ sent++;
72
+ } catch (e) {
73
+ const code = e && e.statusCode;
74
+ if (code === 404 || code === 410) dead.push(endpoint);
75
+ }
76
+ }
77
+ if (dead.length) {
78
+ await jsonfile.update(paths.pushFile(), PUSH_DEFAULT(), (d) => {
79
+ d.subscriptions = (d.subscriptions || []).filter((s) => !dead.includes(s.endpoint));
80
+ });
81
+ }
82
+ return sent;
83
+ }
84
+
85
+ module.exports = { pushEnabled, publicKeyBase64url, pushRead, pushStore, pushRemove, pushSend };