@danieltmn/openbridge 0.3.0 → 0.5.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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,52 @@ Todos los cambios relevantes de OpenBridge. Formato basado en
4
4
  [Keep a Changelog](https://keepachangelog.com/es-ES/1.1.0/) y
5
5
  [Versionado Semantico](https://semver.org/lang/es/).
6
6
 
7
+ ## [0.5.0] - 2026-09-14
8
+
9
+ ### Agregado
10
+
11
+ - Comando `openbridge update` (compara con npm; `--yes` actualiza la instalacion
12
+ global).
13
+ - Comando `openbridge join <url>` para sumar una PC como puente de un hub:
14
+ persiste `apiUrl`/`apiToken`/`bridgeId` y arranca el puente (`--no-start` solo
15
+ guarda).
16
+ - `npm run lint` (`scripts/lint.mjs`): `node --check` sobre todo el JS; corre en
17
+ CI.
18
+ - Test end-to-end del puente con `opencode` mockeado (`test/bridge.test.js`) y
19
+ tests del CLI (`test/cli.test.js`).
20
+ - **MCP**: vista en la web y comando de chat `/mcp` que muestran los servidores
21
+ MCP de opencode y su estado (`opencode mcp list`); comando de puente
22
+ `mcp_list`.
23
+ - `CONTRIBUTING.md`.
24
+
25
+ ### Cambiado
26
+
27
+ - Claves internas `ocx_*` renombradas a `ob_*` con migracion automatica
28
+ (localStorage y cookie de tema). Se mantienen como compatibilidad el env
29
+ `OPENCONEX_HOME` y el bin `openconex` (deprecados).
30
+ - `config.command` puede apuntar a un script de Node (`.js`/`.mjs`/`.cjs`): se
31
+ ejecuta con el `node` actual (wrappers propios y pruebas con CLI simulado).
32
+
33
+ ## [0.4.0] - 2026-09-14
34
+
35
+ ### Agregado
36
+
37
+ - **Multiusuario** con roles `admin`/`user`: login con usuario + contrasena,
38
+ sesion ligada al usuario y a la version de contrasena, rate limit por
39
+ IP+usuario, y comando CLI `users`
40
+ (`list`/`add`/`remove`/`passwd`/`role`/`disable`/`enable`); `passwd --user`.
41
+ - Gate server-side: `session_delete` y los comandos mutantes del puente
42
+ (procesos, tuneles, revertir) solo para `admin`; la web oculta esos controles.
43
+ - **Autor** en cada mensaje (auditoria) y muestra del usuario en el sidebar.
44
+ - **Content-Security-Policy** y `Secure` de cookie solo desde loopback
45
+ (`trust proxy` explicito).
46
+
47
+ ### Cambiado
48
+
49
+ - La sesion se invalida al cambiar la contrasena (`pv`); los usuarios
50
+ deshabilitados no pueden entrar.
51
+ - `app.json` migra el `username`/`password` legado a `users[]`.
52
+
7
53
  ## [0.3.0] - 2026-09-13
8
54
 
9
55
  ### Cambios
package/README.md CHANGED
@@ -44,9 +44,9 @@ openbridge server
44
44
  ```
45
45
 
46
46
  Al arrancar (queda en segundo plano) imprime el **estado con la URL pública**
47
- (`https://….tunnelmole.net/chat.php`): abríla desde el celular y logueate con el
48
- usuario (`admin`) y la contraseña que elegiste en `init`. Para correrlo en
49
- primer plano usá `openbridge server --stream`; para detenerlo, `openbridge stop`.
47
+ (`https://….tunnelmole.net/chat.php`): abríla desde el celular y logueate con tu
48
+ usuario y contraseña. Para correrlo en primer plano usá
49
+ `openbridge server --stream`; para detenerlo, `openbridge stop`.
50
50
 
51
51
  ### Opciones de `init`
52
52
 
@@ -55,7 +55,8 @@ primer plano usá `openbridge server --stream`; para detenerlo, `openbridge stop
55
55
  --name "PC 1" nombre visible de esta computadora
56
56
  --id pc1 identificador corto (default: derivado del nombre)
57
57
  --port 8799 puerto local
58
- --password <clave> contraseña de acceso
58
+ --password <clave> contraseña del admin inicial
59
+ --user <nombre> nombre del admin inicial (default: admin)
59
60
  --tunnel <prov> tunnelmole | ngrok | cloudflare | none
60
61
  --domain <host> dominio fijo del túnel (ngrok)
61
62
  --yes sin preguntas (usa defaults)
@@ -63,12 +64,25 @@ primer plano usá `openbridge server --stream`; para detenerlo, `openbridge stop
63
64
  --dir <ruta> casa portable (default: directorio actual)
64
65
  ```
65
66
 
67
+ ## Actualizar
68
+
69
+ ```bash
70
+ npm i -g @danieltmn/openbridge@latest # o: npx @danieltmn/openbridge@latest …
71
+ ```
72
+
73
+ La configuración y los datos en `.openbridge/` se **migran solos**: el layout
74
+ viejo (archivos sueltos) pasa a `.openbridge/`, y el `username`/`password` de una
75
+ instalación de un solo usuario se convierte en el **primer admin** de `users[]`
76
+ (con la misma contraseña). Por el cambio de formato de sesión, **hay que volver a
77
+ iniciar sesión** una vez; los chats, carpetas, túnel y push se conservan.
78
+
66
79
  ## Comandos
67
80
 
68
81
  | Comando | Qué hace |
69
82
  |---|---|
70
83
  | `openbridge init` | Configura la casa (workspace, contraseña, túnel, puerto) |
71
- | `openbridge passwd` | Cambia la contraseña de acceso (detiene el server si corre) |
84
+ | `openbridge passwd` | Cambia la contraseña de acceso (`--user <nombre>`; detiene el server si corre) |
85
+ | `openbridge users` | Usuarios y roles: `list`, `add`, `remove`, `passwd`, `role`, `disable`, `enable` |
72
86
  | `openbridge server` | Arranca app + puente + túnel en **segundo plano** y muestra el estado (`--stream` = primer plano) |
73
87
  | `openbridge stop` | Detiene el server y su árbol de procesos |
74
88
  | `openbridge status` | Estado, URL, puente en línea y chats |
@@ -76,10 +90,12 @@ primer plano usá `openbridge server --stream`; para detenerlo, `openbridge stop
76
90
  | `openbridge tunnel` | Muestra o cambia el proveedor de túnel y su dominio fijo (`--domain`) |
77
91
  | `openbridge logs` | Logs (`--follow`, `--server`, `--bridge`) |
78
92
  | `openbridge bridge` | Corre **solo** el puente (`--api --token --id --name`) |
93
+ | `openbridge join` | Vincula esta PC como puente de un hub (`<url> --token --id --name`) |
79
94
  | `openbridge import` | Trae `data/` de OpenConex (`<data-dir> [--force]`) |
80
95
  | `openbridge reset` | Borra chats/datos (`--session <id>`, `--yes`) |
81
96
  | `openbridge autostart` | Arranque automático (`install`/`remove`) |
82
97
  | `openbridge doctor` | Verifica Node, opencode, configuración y puerto |
98
+ | `openbridge update` | Busca una versión nueva en npm (`--yes` para actualizar) |
83
99
 
84
100
  ## Casa portable
85
101
 
@@ -89,7 +105,7 @@ Todo vive dentro de **`.openbridge/`** en el directorio donde corrés `init`
89
105
  ```
90
106
  .openbridge/
91
107
  config.json config del puente (apiUrl, token, workspace, bridgeId…)
92
- app.json config de la app (usuario, contraseña, VAPID, puerto, túnel)
108
+ app.json config de la app (usuarios, token, VAPID, puerto, túnel)
93
109
  folders.json lista blanca de carpetas que se ven desde el celular
94
110
  data/ sesiones, mensajes, catálogo, registro de puentes, push
95
111
  logs/ server.log y bridge.log
@@ -99,47 +115,115 @@ Movés la carpeta a donde quieras y sigue funcionando (es portable). Si venías
99
115
  de una versión anterior con los archivos sueltos, se **migran solos** a
100
116
  `.openbridge/` la primera vez que corras un comando.
101
117
 
102
- ## Varias computadoras
118
+ ## Varias computadoras (hub + remotas)
119
+
120
+ La PC **hub** corre la app y el túnel; las demás PCs corren solo el **puente** y
121
+ apuntan al hub. En el sidebar de la web elegís qué PC usar y ves sus proyectos y
122
+ chats.
123
+
124
+ Ejemplo concreto:
125
+
126
+ 1. En el **hub** (tiene la app y la URL pública):
127
+ ```bash
128
+ openbridge init
129
+ openbridge server # muestra estado, URL y QR
130
+ openbridge status # volvés a ver la URL cuando quieras
131
+ ```
132
+ 2. Copiá el **token del puente** del hub: `.openbridge/app.json` → `bridgeToken`.
133
+ 3. En la **PC 2** (remota):
134
+ ```bash
135
+ openbridge join https://tu-url-publica --token <bridgeToken> --id pc2 --name "PC 2"
136
+ # guarda la config y arranca el puente; con --no-start solo guarda
137
+ ```
138
+ (equivale a `openbridge bridge --api … --token … --id … --name …`, pero
139
+ persiste la config para no repetir flags)
140
+ 4. Abrí la URL del hub desde el celular: en el sidebar aparecen **PC 1** y
141
+ **PC 2** para alternar.
142
+
143
+ El puente remoto solo necesita salida a internet hacia el hub; no abre puertos ni
144
+ túnel propio. Para que arranque solo en cada PC: `openbridge autostart install`.
103
145
 
104
- El hub (la PC con la app) puede atender **varias PCs** a la vez:
105
-
106
- - En la PC "hub": `openbridge init` + `openbridge server` (queda la app + túnel).
107
- - En otra PC: `openbridge init` y luego `openbridge bridge --api <url-del-hub>
108
- --token <token> --id pc2 --name "PC 2"` (sin editar `config.json` a mano).
146
+ ## Túnel y URL estable
109
147
 
110
- En el sidebar de la web elegís qué PC usar y ves sus proyectos y chats.
148
+ Proveedores (`--tunnel` o `openbridge tunnel <prov>`): `tunnelmole` (gratis, sin
149
+ cuenta), `ngrok` (cuenta + authtoken; admite **dominio fijo**) y `cloudflare`
150
+ (quick tunnel). Ver/cambiar sin reconfigurar todo:
111
151
 
112
- ## Túnel y URL estable
152
+ ```bash
153
+ openbridge tunnel # estado actual
154
+ openbridge tunnel ngrok --domain mi-pc.ngrok.app # URL fija (recomendado)
155
+ openbridge stop && openbridge server # reiniciar para aplicar
156
+ ```
113
157
 
114
- Proveedores soportados (`--tunnel`): `tunnelmole` (gratis, sin cuenta),
115
- `ngrok` (cuenta + authtoken; admite dominio fijo con `--domain` en
116
- `app.json`) y `cloudflare` (quick tunnel de `cloudflared`). Con URL
117
- **aleatoria**, la suscripción push no persiste entre reinicios (el push está
118
- atado al origen). Para PWA/push estables conviene ngrok con dominio fijo o
119
- Cloudflare con dominio propio.
158
+ Con URL **aleatoria** la PWA y el push no persisten entre reinicios (están atados
159
+ al origen). Para una URL estable usá ngrok con dominio fijo (o un named tunnel de
160
+ Cloudflare). Escaneá el QR de `openbridge status`/`openbridge qr` para abrirla en
161
+ el celular.
120
162
 
121
163
  ## Avisos push
122
164
 
123
- Las claves VAPID se generan solas en `init`. Con URL de túnel **aleatoria**, la
124
- suscripción push no persiste entre reinicios (el push está atado al origen). Para
125
- una URL fija hace falta un proveedor con dominio estable (ngrok/Cloudflare).
165
+ Las claves VAPID se generan solas en `init`. Con URL de túnel aleatoria la
166
+ suscripción no persiste entre reinicios (el push está atado al origen); la app la
167
+ vuelve a registrar cuando la abrís en la URL nueva. Para que sea estable, usá una
168
+ URL fija (ngrok con `--domain`).
169
+
170
+ ## MCP
171
+
172
+ La web tiene una vista **mcp** y el chat acepta `/mcp`: muestran los servidores
173
+ MCP de opencode y su estado (`opencode mcp list`). El comando corre en la PC del
174
+ puente, así que refleja la configuración de opencode de esa computadora.
126
175
 
127
176
  ## Seguridad
128
177
 
129
- - La app escucha solo en `127.0.0.1`; el túnel la expone.
130
- - Login con contraseña (scrypt) + CSRF y rate limit (5 intentos); token del
131
- puente autogenerado.
178
+ - La app escucha **solo en `127.0.0.1`**; el túnel la expone a internet.
179
+ - Login con **usuario y contraseña** (scrypt), cookie firmada `HttpOnly` +
180
+ `SameSite=Lax`, **CSRF** y **rate limit** (5 intentos / 15 min por IP+usuario);
181
+ token del puente autogenerado.
182
+ - La cookie usa `Secure` cuando el pedido llega por HTTPS **desde loopback** (el
183
+ túnel); no se confía en `X-Forwarded-Proto` de otros orígenes.
184
+ - Respuestas con `Content-Security-Policy` (orígenes externos, frames y objetos
185
+ bloqueados), `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY` y
186
+ `Referrer-Policy: no-referrer`.
132
187
  - El túnel es **público mientras corre**: detenelo (`openbridge stop`) cuando no
133
- lo uses.
188
+ lo uses y mantené la contraseña fuerte.
189
+
190
+ ## Usuarios y roles
191
+
192
+ Cada persona entra con **usuario y contraseña**. Hay dos roles:
193
+
194
+ - **admin**: todo (crear/borrar chats, revertir cambios, procesos, túneles y
195
+ administrar usuarios).
196
+ - **user**: chat, archivos, cambios y búsqueda; no puede borrar chats, revertir
197
+ cambios ni correr procesos/túneles.
198
+
199
+ Se administran desde la PC con la CLI (los cambios requieren reiniciar el server):
200
+
201
+ ```bash
202
+ openbridge users list
203
+ openbridge users add ana --role user --password <clave>
204
+ openbridge users passwd ana
205
+ openbridge users role ana admin
206
+ openbridge users disable ana # enable para reactivar
207
+ openbridge users remove ana
208
+ openbridge passwd --user admin # cambia la clave de un usuario
209
+ ```
210
+
211
+ `init --user <nombre>` crea el admin inicial. La contraseña se guarda con scrypt,
212
+ y cambiar una clave **invalida las sesiones abiertas** de ese usuario. El último
213
+ admin no se puede borrar, degradar ni deshabilitar. Cada mensaje guarda **quién
214
+ lo envió** (se ve en el chat).
134
215
 
135
216
  ## Estado
136
217
 
137
218
  Proyecto en desarrollo. Ya funciona: `init`, `server` (segundo plano; `--stream`
138
- en primer plano), `stop`, `status`, `logs` (`--follow`), `bridge` (con flags),
139
- `passwd`, `import`, `reset`, `autostart`, `doctor`, login con rate limit, API
140
- completa, SSE, catálogo por PC, Web Push y túnel (TunnelMole/ngrok/cloudflare).
141
- Tests en `npm test`. Pendiente: re-suscripción push al cambiar la URL y más
142
- cobertura end-to-end.
219
+ en primer plano), `stop`, `status`, `qr`, `tunnel`, `logs` (`--follow`), `bridge`
220
+ (con flags), `passwd`, `users`, `import`, `reset`, `autostart`, `doctor`; login
221
+ **multiusuario** con roles `admin`/`user` y rate limit; API completa y SSE;
222
+ catálogo por PC; Web Push; túnel (TunnelMole/ngrok/cloudflare); y en la web: chat
223
+ con streaming, adjuntar imagen, dictado por voz, plantillas de prompts,
224
+ tokens/contexto y **costo** por sesión, vista de archivos, vista de **cambios**
225
+ (git status/diff) con **revertir**, búsqueda global, sesiones de opencode y
226
+ **autor** en cada mensaje. Tests en `npm test` (28).
143
227
 
144
228
  ## Licencia
145
229
 
@@ -19,18 +19,20 @@ el estado); `--stream` lo deja en primer plano.
19
19
  | Ruta | Responsabilidad |
20
20
  |---|---|
21
21
  | `bin/openbridge.js` | Entrada CLI + guard de Node. |
22
- | `src/cli.js` | Comandos (`init`, `passwd`, `server`, `stop`, `status`, `logs`, `bridge`, `import`, `reset`, `autostart`, `doctor`). |
22
+ | `src/cli.js` | Comandos (`init`, `passwd`, `users`, `server`, `stop`, `status`, `qr`, `tunnel`, `logs`, `bridge`, `join`, `import`, `reset`, `autostart`, `doctor`, `update`). |
23
23
  | `src/paths.js` | Casa portable: ubicacion de `config.json`, `app.json`, `data/`, `logs/`. |
24
- | `src/config.js` | Lectura/escritura de config, hash scrypt, VAPID. |
25
- | `src/auth.js` | Cookie de sesion firmada, CSRF, remember-me, rate limit, token del puente. |
24
+ | `src/config.js` | Lectura/escritura de config, hash scrypt, **usuarios/roles**, VAPID. |
25
+ | `src/auth.js` | Cookie de sesion firmada, CSRF, remember-me, **roles**, rate limit, token del puente. |
26
26
  | `src/log.js` | Log a consola + `logs/server.log`. |
27
27
  | `src/push.js` | Web Push (VAPID + `web-push`). |
28
+ | `src/qr.js` | Generador QR propio (terminal/SVG), sin dependencias. |
28
29
  | `src/tunnel/` | Proveedores de tunel enchufables. |
29
- | `src/web/server.js` | Servidor HTTP, estaticos, seguridad. |
30
- | `src/web/routes.js` | Router de paginas y API (`?action=...`), compatible con `app.js`/`bridge.js`. |
30
+ | `src/web/server.js` | Servidor HTTP, estaticos, cabeceras de seguridad (CSP). |
31
+ | `src/web/routes.js` | Router de paginas y API (`?action=...`), gate de roles, compatible con `app.js`/`bridge.js`. |
31
32
  | `src/web/assets/` | Frontend (`app.js`, temas, iconos, service worker, manifest). |
32
33
  | `src/store/` | Store JSON con mutex por archivo y escritura atomica. |
33
- | `src/bridge/bridge.js` | Puente: sincroniza catalogo, hace poll y ejecuta opencode. |
34
+ | `src/bridge/bridge.js` | Puente: sincroniza catalogo, hace poll y ejecuta opencode; comandos fs/proc/tunel/git. |
35
+ | `scripts/release.mjs` | Release local (fuera del paquete npm): version, changelog, tag, push y publish. |
34
36
 
35
37
  ## Casa portable
36
38
 
@@ -53,7 +55,8 @@ OpenBridge. La CLI pasa la **base** (`--dir`/cwd) al puente via
53
55
 
54
56
  ## Flujo de un mensaje
55
57
 
56
- 1. El celular envia `?action=send` (con CSRF) -> `src/web/routes.js`.
58
+ 1. El celular envia `?action=send` (con CSRF) -> `src/web/routes.js`; el mensaje
59
+ se guarda con el **autor** de la sesion.
57
60
  2. El mensaje queda `pending` en `data/messages-<id>.json`.
58
61
  3. El puente hace `poll`, reclama el mensaje y corre
59
62
  `opencode run --model <m> [--session <id>]`.
@@ -62,16 +65,58 @@ OpenBridge. La CLI pasa la **base** (`--dir`/cwd) al puente via
62
65
 
63
66
  ## Multi-PC
64
67
 
65
- El hub corre la app; cada PC corre `openbridge bridge` apuntando al hub
66
- (`apiUrl` + `apiToken` + `bridgeId`). El catalogo se guarda por PC
67
- (`data/catalog-<id>.json`) y el sidebar permite elegir que PC usar.
68
+ El hub corre la app; cada PC corre `openbridge bridge` (o `openbridge join <url>`,
69
+ que persiste `apiUrl` + `apiToken` + `bridgeId`) apuntando al hub. El catalogo se
70
+ guarda por PC (`data/catalog-<id>.json`) y el sidebar permite elegir que PC usar.
71
+
72
+ `config.command` es el CLI a ejecutar (default `opencode`). Si apunta a un script
73
+ de Node (`.js`/`.mjs`/`.cjs`) el puente lo corre con el `node` actual, lo que
74
+ permite wrappers propios y las pruebas con un opencode simulado.
75
+
76
+ ## Usuarios y roles
77
+
78
+ `app.json` guarda `users[]` (`id`, `name`, `role`, `password` scrypt, `pv`,
79
+ `disabled`). El modelo viejo de un `username`/`password` se migra solo a
80
+ `users[0]` (admin).
81
+
82
+ - La sesion (cookie firmada) guarda el `id` del usuario y su `pv`; el **rol se lee
83
+ siempre del server** en cada request. Cambiar la contrasena sube `pv` e invalida
84
+ las sesiones abiertas; un usuario `disabled` no entra.
85
+ - Roles: `admin` (todo) y `user` (chat/lectura). El gate es **server-side** en
86
+ `routes.js`; la UI solo oculta los controles.
87
+ - Rate limit de login por IP+usuario y error generico (no filtra si el usuario
88
+ existe; corre scrypt igual contra un hash dummy).
89
+ - Cada mensaje guarda `author` (quien lo envio); la respuesta hereda el autor del
90
+ pedido.
91
+
92
+ ## Comandos del puente
93
+
94
+ La web encola comandos (`?action=run_oc`) que el puente reclama por el poll y
95
+ resuelve (los resultados vuelven por `command_done`/`fs_result`). Sirve tanto en
96
+ local como en remoto:
97
+
98
+ - Lectura: `models`, `session_list`, `session_info`, `opencode_version`,
99
+ `fs_list`, `fs_read`, `proc_list`, `proc_log`, `tunnel_list`, `git_status`,
100
+ `git_diff`.
101
+ - Mutantes (solo `admin`): `proc_start`, `proc_stop`, `tunnel_start`,
102
+ `tunnel_stop`, `git_checkout`.
103
+
104
+ Las rutas se validan contra el workspace (`procResolveFolder`/`resolveInWorkspace`)
105
+ y los argumentos con una whitelist (`OC_ALLOWED`).
68
106
 
69
107
  ## Decisiones
70
108
 
71
109
  - **Stateless sessions**: la cookie se firma con HMAC; no hay store de sesiones.
110
+ El `id`+`pv` del usuario van en el payload y el rol se resuelve en cada request.
111
+ - **Multiusuario con roles**: `admin`/`user`, gate server-side; el ultimo admin no
112
+ se puede borrar, degradar ni deshabilitar.
72
113
  - **Escritura atomica + mutex**: `tmp` + `rename` y una cola por archivo evitan
73
114
  corrupciones con polls concurrentes.
74
115
  - **Sin dependencias de framework**: HTTP nativo de Node; la unica dependencia
75
- es `web-push`.
116
+ es `web-push`. El QR se genera con codigo propio (`src/qr.js`).
117
+ - **Cabeceras de seguridad**: CSP restrictiva; permite scripts/estilos inline
118
+ porque los templates los usan.
119
+ - **Cola de comandos del puente**: un solo canal (poll) para fs, procesos,
120
+ tuneles y git, valido igual en local y remoto.
76
121
  - **Proveedores de tunel detras de una interfaz**: `startTunnel(port, provider)`
77
122
  devuelve `{ url, provider, pid, stop }`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danieltmn/openbridge",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Tu opencode en el celular, sin hosting: corre la app, el puente y un tunel publico desde tu PC.",
5
5
  "type": "commonjs",
6
6
  "author": "tamnora",
@@ -43,6 +43,7 @@
43
43
  "scripts": {
44
44
  "start": "node bin/openbridge.js server",
45
45
  "test": "node --test",
46
+ "lint": "node scripts/lint.mjs",
46
47
  "release": "node scripts/release.mjs",
47
48
  "release:dry": "node scripts/release.mjs --dry-run",
48
49
  "prepublishOnly": "npm test"
package/src/auth.js CHANGED
@@ -38,8 +38,16 @@ function parseCookies(header) {
38
38
  return out;
39
39
  }
40
40
 
41
+ // Detras del tunel el server recibe los pedidos desde loopback (el tunel corre
42
+ // en la misma maquina y reenvia a 127.0.0.1). Solo en ese caso confiamos en
43
+ // X-Forwarded-Proto: un cliente que llegara directo por la red no puede forzar
44
+ // el flag Secure de la cookie. Sin info de socket (tests), se confia.
41
45
  function isSecure(req) {
42
- const proto = req.headers['x-forwarded-proto'];
46
+ if (req.socket && req.socket.encrypted) return true;
47
+ const remote = req.socket && req.socket.remoteAddress;
48
+ const loopback = !remote || remote === '127.0.0.1' || remote === '::1' || remote === '::ffff:127.0.0.1';
49
+ if (!loopback) return false;
50
+ const proto = String(req.headers['x-forwarded-proto'] || '').split(',')[0].trim().toLowerCase();
43
51
  return proto === 'https';
44
52
  }
45
53
 
@@ -51,10 +59,10 @@ function serializeCookie(name, value, req, maxAge) {
51
59
  }
52
60
 
53
61
  // ---------------------------------------------------------------------------
54
- // Sesion
62
+ // Sesion (ligada al id del usuario; el rol se lee siempre del server)
55
63
  // ---------------------------------------------------------------------------
56
- function makeSession(app, username, csrf, exp) {
57
- const payload = b64url(JSON.stringify({ u: username, c: csrf, e: exp }));
64
+ function makeSession(app, user, csrf, exp) {
65
+ const payload = b64url(JSON.stringify({ u: user.id, pv: parseInt(user.pv, 10) || 1, c: csrf, e: exp }));
58
66
  return payload + '.' + sign(payload, app.csrfSecret);
59
67
  }
60
68
  function readSession(app, req) {
@@ -68,8 +76,11 @@ function readSession(app, req) {
68
76
  if (!safeEqual(sign(payload, app.csrfSecret), sig)) return null;
69
77
  let data;
70
78
  try { data = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')); } catch (e) { return null; }
71
- if (!data || data.u !== app.username || typeof data.e !== 'number' || data.e < Math.floor(Date.now() / 1000)) return null;
72
- return data;
79
+ if (!data || typeof data.e !== 'number' || data.e < Math.floor(Date.now() / 1000)) return null;
80
+ const user = config.userById(app, data.u);
81
+ if (!user || user.disabled) return null;
82
+ if ((parseInt(user.pv, 10) || 1) !== (parseInt(data.pv, 10) || 1)) return null;
83
+ return { id: user.id, name: user.name, role: user.role, u: user.id, c: data.c, e: data.e };
73
84
  }
74
85
  function currentCsrf(app, req) {
75
86
  const s = readSession(app, req);
@@ -80,6 +91,14 @@ function requireLogin(app, req, res) {
80
91
  if (!s) { json(res, 401, { ok: false, error: 'No autorizado' }); return null; }
81
92
  return s;
82
93
  }
94
+ // Exige que el usuario tenga uno de los roles indicados.
95
+ function requireRole(app, req, res, roles) {
96
+ const s = readSession(app, req);
97
+ if (!s) { json(res, 401, { ok: false, error: 'No autorizado' }); return null; }
98
+ const allowed = Array.isArray(roles) ? roles : [roles];
99
+ if (!allowed.includes(s.role)) { json(res, 403, { ok: false, error: 'Permiso insuficiente' }); return null; }
100
+ return s;
101
+ }
83
102
  function requireCsrf(app, req, res) {
84
103
  const s = readSession(app, req);
85
104
  const given = String(req.headers['x-csrf'] || '').trim();
@@ -90,13 +109,13 @@ function requireCsrf(app, req, res) {
90
109
  return s;
91
110
  }
92
111
 
93
- function startSession(app, req, res, username) {
112
+ function startSession(app, req, res, user) {
94
113
  const csrf = crypto.randomBytes(16).toString('hex');
95
114
  const exp = Math.floor(Date.now() / 1000) + REMEMBER_DAYS * 86400;
96
- const token = makeSession(app, username, csrf, exp);
115
+ const token = makeSession(app, user, csrf, exp);
97
116
  res.setHeader('Set-Cookie', [
98
117
  serializeCookie(SESSION_COOKIE, token, req, REMEMBER_DAYS * 86400),
99
- serializeCookie(REMEMBER_COOKIE, makeRemember(app, username), req, REMEMBER_DAYS * 86400),
118
+ serializeCookie(REMEMBER_COOKIE, makeRemember(app, user), req, REMEMBER_DAYS * 86400),
100
119
  ]);
101
120
  return csrf;
102
121
  }
@@ -107,9 +126,9 @@ function endSession(req, res) {
107
126
  ]);
108
127
  }
109
128
 
110
- function makeRemember(app, username) {
129
+ function makeRemember(app, user) {
111
130
  const exp = Math.floor(Date.now() / 1000) + REMEMBER_DAYS * 86400;
112
- const payload = username + '|' + exp;
131
+ const payload = user.id + '|' + exp + '|' + (parseInt(user.pv, 10) || 1);
113
132
  return b64url(payload) + '.' + sign(payload, app.csrfSecret);
114
133
  }
115
134
  // Re-autentica desde la cookie remember si la sesion se perdio.
@@ -125,10 +144,12 @@ function rememberAutoLogin(app, req, res) {
125
144
  let plain;
126
145
  try { plain = Buffer.from(payload, 'base64url').toString('utf8'); } catch (e) { return false; }
127
146
  const parts = plain.split('|');
128
- if (parts.length !== 2) return false;
129
- const [user, exp] = parts;
130
- if (user !== app.username || !/^\d+$/.test(exp) || parseInt(exp, 10) < Math.floor(Date.now() / 1000)) return false;
147
+ if (parts.length !== 3) return false;
148
+ const [uid, exp, pv] = parts;
149
+ if (!/^\d+$/.test(exp) || parseInt(exp, 10) < Math.floor(Date.now() / 1000)) return false;
131
150
  if (!safeEqual(sign(payload, app.csrfSecret), sig)) return false;
151
+ const user = config.userById(app, uid);
152
+ if (!user || user.disabled || (parseInt(user.pv, 10) || 1) !== (parseInt(pv, 10) || 1)) return false;
132
153
  startSession(app, req, res, user);
133
154
  return true;
134
155
  }
@@ -141,10 +162,10 @@ const LOGIN_WINDOW_MS = 15 * 60 * 1000;
141
162
  const LOGIN_LOCK_MS = 15 * 60 * 1000;
142
163
  const loginAttempts = new Map();
143
164
 
144
- function clientKey(req) {
165
+ function clientKey(req, user) {
145
166
  const xff = String(req.headers['x-forwarded-for'] || '').split(',')[0].trim();
146
167
  const ip = xff || (req.socket && req.socket.remoteAddress) || 'desconocido';
147
- return String(ip);
168
+ return String(ip) + '|' + String(user || '').trim().toLowerCase();
148
169
  }
149
170
  function pruneLoginAttempts(now) {
150
171
  for (const [key, st] of loginAttempts) {
@@ -152,25 +173,25 @@ function pruneLoginAttempts(now) {
152
173
  }
153
174
  }
154
175
  // Segundos restantes de bloqueo (0 = puede intentar).
155
- function loginLockRemaining(req) {
176
+ function loginLockRemaining(req, user) {
156
177
  const now = Date.now();
157
178
  pruneLoginAttempts(now);
158
- const st = loginAttempts.get(clientKey(req));
179
+ const st = loginAttempts.get(clientKey(req, user));
159
180
  if (!st || !st.lockUntil) return 0;
160
181
  const left = st.lockUntil - now;
161
182
  return left > 0 ? Math.ceil(left / 1000) : 0;
162
183
  }
163
- function loginRecordFailure(req) {
184
+ function loginRecordFailure(req, user) {
164
185
  const now = Date.now();
165
- const key = clientKey(req);
186
+ const key = clientKey(req, user);
166
187
  let st = loginAttempts.get(key);
167
188
  if (!st || (!st.lockUntil && now - st.first > LOGIN_WINDOW_MS)) st = { count: 0, first: now, lockUntil: 0 };
168
189
  st.count++;
169
190
  if (st.count >= LOGIN_MAX_ATTEMPTS) st.lockUntil = now + LOGIN_LOCK_MS;
170
191
  loginAttempts.set(key, st);
171
192
  }
172
- function loginClear(req) {
173
- loginAttempts.delete(clientKey(req));
193
+ function loginClear(req, user) {
194
+ loginAttempts.delete(clientKey(req, user));
174
195
  }
175
196
 
176
197
  // ---------------------------------------------------------------------------
@@ -197,7 +218,7 @@ function json(res, code, data) {
197
218
  module.exports = {
198
219
  SESSION_COOKIE, REMEMBER_COOKIE,
199
220
  parseCookies, serializeCookie, isSecure,
200
- makeSession, readSession, currentCsrf, requireLogin, requireCsrf,
221
+ makeSession, readSession, currentCsrf, requireLogin, requireRole, requireCsrf,
201
222
  startSession, endSession, rememberAutoLogin,
202
223
  loginLockRemaining, loginRecordFailure, loginClear,
203
224
  checkBridgeToken, safeEqual, json,
@@ -412,6 +412,21 @@ function parseShimExe(shimPath, shimDir) {
412
412
  return exe;
413
413
  }
414
414
 
415
+ // Si `command` apunta a un script de Node (.js/.mjs/.cjs) lo ejecutamos con el
416
+ // node actual. Permite wrappers propios (p. ej. un runner que filtra logs) y
417
+ // que las pruebas usen un opencode simulado sin depender del PATH.
418
+ function isNodeScriptCommand(cmd) {
419
+ return /\.(c|m)?js$/i.test(String(cmd || '').trim());
420
+ }
421
+
422
+ function commandSpec(args) {
423
+ const cmd = config.command;
424
+ if (isNodeScriptCommand(cmd)) {
425
+ return { cmd: process.execPath, argv: [path.resolve(cmd)].concat(args) };
426
+ }
427
+ return { cmd: resolveCommand(), argv: args };
428
+ }
429
+
415
430
  // ---------------------------------------------------------------------------
416
431
  // Ejecutar comandos auxiliares de opencode
417
432
  // ---------------------------------------------------------------------------
@@ -457,9 +472,9 @@ function runCmd(bin, args, opts) {
457
472
 
458
473
  function runCli(args, opts) {
459
474
  return new Promise((resolve) => {
460
- const cmd = resolveCommand();
475
+ const spec = commandSpec(args);
461
476
  const timeoutMs = Math.max((opts && opts.timeout) || 0, 0);
462
- const child = spawn(cmd, args, {
477
+ const child = spawn(spec.cmd, spec.argv, {
463
478
  cwd: (opts && opts.cwd) || undefined,
464
479
  stdio: ['pipe', 'pipe', 'pipe'],
465
480
  env: process.env,
@@ -511,9 +526,9 @@ function runCli(args, opts) {
511
526
  // Resolución: { ok, code, text, reasoning, killed, canceled, sessionID, errorText }.
512
527
  function streamCli(args, opts, onPartial) {
513
528
  return new Promise((resolve) => {
514
- const cmd = resolveCommand();
529
+ const spec = commandSpec(args);
515
530
  const timeoutMs = Math.max((opts && opts.timeout) || 0, 0);
516
- const child = spawn(cmd, args, {
531
+ const child = spawn(spec.cmd, spec.argv, {
517
532
  cwd: (opts && opts.cwd) || undefined,
518
533
  stdio: ['pipe', 'pipe', 'pipe'],
519
534
  env: process.env,
@@ -834,7 +849,7 @@ async function runOpencode(msg, onPartial) {
834
849
  try {
835
850
  const mime = msg.img.slice(5, msg.img.indexOf(';'));
836
851
  const ext = { 'image/png': '.png', 'image/jpeg': '.jpg', 'image/webp': '.webp', 'image/gif': '.gif' }[mime] || '.png';
837
- imgPath = path.join(os.tmpdir(), 'ocx-img-' + msg.session_id + '-' + msg.id + ext);
852
+ imgPath = path.join(os.tmpdir(), 'ob-img-' + msg.session_id + '-' + msg.id + ext);
838
853
  fs.writeFileSync(imgPath, Buffer.from(msg.img.slice(msg.img.indexOf(',') + 1), 'base64'));
839
854
  log('imagen adjunta: ' + Math.round(fs.statSync(imgPath).size / 1024) + ' KB para el mensaje #' + msg.id);
840
855
  } catch (e) {
@@ -975,6 +990,16 @@ async function runMessage(msg) {
975
990
  return { text: '', opencodeSession: null, done: true };
976
991
  }
977
992
 
993
+ if (name === 'mcp') {
994
+ log('comando /mcp');
995
+ const r = await runCli(['mcp', 'list'], { timeout: 30000 });
996
+ const text = r.ok
997
+ ? (r.text || 'No hay servidores MCP configurados.')
998
+ : 'No se pudieron listar los MCP: ' + (r.text || ('exit ' + r.code));
999
+ await respondSimple(msg, text, false);
1000
+ return { text: '', opencodeSession: null, done: true };
1001
+ }
1002
+
978
1003
  if (name === 'workspace') {
979
1004
  log('comando /workspace');
980
1005
  const ws = config.workspace ? path.resolve(config.workspace) : '';
@@ -1014,6 +1039,7 @@ async function runMessage(msg) {
1014
1039
  + '• /compact, /resumen, /summarize — liberar contexto\n'
1015
1040
  + '• /models [filtro] — proveedores disponibles o búsqueda de modelos\n'
1016
1041
  + '• /agents — listar agentes disponibles\n'
1042
+ + '• /mcp — servidores MCP de opencode y su estado\n'
1017
1043
  + '• /folders, /carpetas, /dirs — listar carpetas del workspace\n'
1018
1044
  + '• /workspace — mostrar el espacio de trabajo\n'
1019
1045
  + '• /status, /estado — estado del puente\n'
@@ -2115,6 +2141,19 @@ async function handleCommand(cmd) {
2115
2141
  await handleFsCommand(cmd);
2116
2142
  return;
2117
2143
  }
2144
+ // Servidores MCP de opencode: se devuelve JSON con la salida cruda para que
2145
+ // la web la muestre en el panel.
2146
+ if (cmd.name === 'mcp_list') {
2147
+ const r = await runCli(['mcp', 'list'], { timeout: 30000 });
2148
+ await api('command_done', {
2149
+ id: cmd.id,
2150
+ ok: r.ok,
2151
+ text: JSON.stringify({ output: r.text || '', ok: r.ok }),
2152
+ error: r.ok ? '' : ('exit ' + r.code),
2153
+ }, cmd._t);
2154
+ log('comando #' + cmd.id + ' mcp_list (ok=' + r.ok + ')');
2155
+ return;
2156
+ }
2118
2157
  // Cambios git del proyecto (status/diff/revertir).
2119
2158
  if (cmd.name === 'git_status' || cmd.name === 'git_diff' || cmd.name === 'git_checkout') {
2120
2159
  await handleGitCommand(cmd);