@danieltmn/tamnora 0.9.6 → 0.11.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.
@@ -4,8 +4,9 @@ import { bridgeStatePath } from './config/home.js';
4
4
  import { pidAlive, killPidTree } from './pid.js';
5
5
 
6
6
  /**
7
- * Tuneles publicos. v1 soporta tunnelmole (gratis, sin cuenta; URL aleatoria)
8
- * via `npx --yes tunnelmole <puerto>`.
7
+ * Acceso remoto. Proveedores:
8
+ * - tailscale: VPN mesh privada + `tailscale serve` (recomendado; sin exposicion).
9
+ * - tunnelmole: tunel publico gratis (URL aleatoria); solo para `preview`.
9
10
  */
10
11
 
11
12
  function killTree(child) {
@@ -32,7 +33,9 @@ export function tunnelState() {
32
33
  let state = null;
33
34
  try {
34
35
  const parsed = JSON.parse(readFileSync(statePath, 'utf8'));
35
- if (parsed?.pid && pidAlive(parsed.pid)) state = parsed;
36
+ // Tailscale es una red privada que entra por loopback: no es un tunel
37
+ // publico, asi que no debe degradar los guards locales (`localOk`).
38
+ if (parsed?.provider !== 'tailscale' && parsed?.pid && pidAlive(parsed.pid)) state = parsed;
36
39
  } catch {
37
40
  /* sin bridge */
38
41
  }
@@ -109,6 +112,7 @@ function spawnTunnel(bin, args, opts = {}) {
109
112
  });
110
113
  }
111
114
 
115
+ // Tunel publico para el feature `preview` (no lo usa `bridge`).
112
116
  export function startTunnelmole(port, opts = {}) {
113
117
  return spawnTunnel('npx', ['--yes', 'tunnelmole', String(port)], {
114
118
  ...opts,
@@ -120,155 +124,121 @@ export function startTunnelmole(port, opts = {}) {
120
124
  }
121
125
 
122
126
  /**
123
- * Relay propio (harness.tamnora.com): mailbox HTTP sobre cPanel (solo PHP).
124
- * El daemon no recibe conexiones: llama hacia afuera (a=poll), ejecuta cada
125
- * request contra el server local y empuja las respuestas (a=push).
127
+ * Tailscale: VPN mesh (WireGuard). `tailscale serve` publica HTTPS dentro del
128
+ * tailnet reenviando a loopback, asi que no hay exposicion publica y las
129
+ * peticiones siguen siendo "locales" para el server. Proveedor recomendado.
126
130
  */
127
- export function startRelay(port, opts = {}) {
128
- const relayUrl = String(opts.relayUrl ?? '').replace(/\/+$/, '');
129
- const token = String(opts.token ?? '');
130
- if (!relayUrl || !/^https?:\/\//.test(relayUrl)) {
131
- throw new Error('relay: falta tunnel.relayUrl (https://harness.tamnora.com/index.php)');
132
- }
133
- if (!token) throw new Error('relay: falta tunnel.token');
134
- const log = opts.log ?? (() => {});
135
- const local = `http://127.0.0.1:${port}`;
136
- const state = { stopped: false };
137
-
138
- const call = async (action, body = {}, { timeoutMs = 30000 } = {}) => {
139
- const res = await fetch(`${relayUrl}?a=${action}`, {
140
- method: 'POST',
141
- headers: { 'Content-Type': 'application/json' },
142
- body: JSON.stringify({ token, ...body }),
143
- signal: AbortSignal.timeout(timeoutMs),
144
- });
145
- if (!res.ok) throw new Error(`relay ${action}: HTTP ${res.status}`);
146
- return res.json();
147
- };
148
-
149
- async function push(rid, chunks) {
150
- await call('push', { rid, chunks }, { timeoutMs: 60000 });
151
- }
152
-
153
- async function handleRequest(row) {
154
- const envelope = typeof row.payload === 'string' ? JSON.parse(row.payload) : row.payload;
155
- const method = envelope.method ?? 'GET';
156
- const path = String(envelope.path ?? '/');
157
- const target = `${local}${path.startsWith('/') ? path : `/${path}`}`;
158
- const init = { method, headers: { ...envelope.headers } };
159
- delete init.headers.host;
160
- if (envelope.body != null) init.body = envelope.body;
161
- const res = await fetch(target, init);
162
- const contentType = String(res.headers.get('content-type') ?? '');
163
- const flat = {};
164
- for (const header of ['content-type']) {
165
- const value = res.headers.get(header);
166
- if (value) flat[header] = value;
167
- }
168
- const cookies = res.headers.getSetCookie?.() ?? [];
169
- if (cookies.length) flat['set-cookie'] = cookies;
170
-
171
- if (!contentType.includes('text/event-stream')) {
172
- const buffer = Buffer.from(await res.arrayBuffer());
173
- await push(row.id, [
174
- {
175
- status: res.status,
176
- headers: flat,
177
- body: buffer.toString('base64'),
178
- done: true,
179
- },
180
- ]);
131
+ function runTailscale(args, opts = {}) {
132
+ const timeoutMs = opts.timeoutMs || 15000;
133
+ return new Promise((resolve, reject) => {
134
+ let child;
135
+ try {
136
+ child = spawn('tailscale', args, {
137
+ stdio: ['ignore', 'pipe', 'pipe'],
138
+ windowsHide: true,
139
+ shell: process.platform === 'win32',
140
+ });
141
+ } catch (err) {
142
+ reject(err);
181
143
  return;
182
144
  }
183
-
184
- // SSE: reenviamos cada frame tal cual (el cliente sabe reconectar con cursor).
185
- let seq = 0;
186
- await push(row.id, [{ seq: seq++, status: res.status, headers: flat, done: false }]);
187
- const reader = res.body.getReader();
188
- const decoder = new TextDecoder();
189
- let buffer = '';
190
- for (;;) {
191
- const { value, done } = await reader.read();
192
- if (done) break;
193
- buffer += decoder.decode(value, { stream: true });
194
- let index;
195
- while ((index = buffer.indexOf('\n\n')) !== -1) {
196
- const frame = buffer.slice(0, index);
197
- buffer = buffer.slice(index + 2);
198
- let ev = '';
199
- let data = '';
200
- let id = null;
201
- for (const line of frame.split('\n')) {
202
- if (line.startsWith('event:')) ev = line.slice(6).trim();
203
- else if (line.startsWith('data:')) data += line.slice(5).trim();
204
- else if (line.startsWith('id:')) id = line.slice(3).trim();
205
- }
206
- if (!ev && !data) continue;
207
- await push(row.id, [{ seq: seq++, ev: ev || 'message', data, id, done: false }]);
145
+ let out = '';
146
+ let err = '';
147
+ let settled = false;
148
+ const timer = setTimeout(() => {
149
+ if (settled) return;
150
+ settled = true;
151
+ killTree(child);
152
+ reject(new Error(`tailscale ${args.join(' ')} no respondio (${Math.round(timeoutMs / 1000)} s).`));
153
+ }, timeoutMs);
154
+ child.stdout.on('data', (data) => {
155
+ out += String(data);
156
+ });
157
+ child.stderr.on('data', (data) => {
158
+ err += String(data);
159
+ });
160
+ child.on('error', (error) => {
161
+ if (settled) return;
162
+ settled = true;
163
+ clearTimeout(timer);
164
+ reject(new Error(`no se pudo ejecutar tailscale (${error.message}). Instalalo: https://tailscale.com/download`));
165
+ });
166
+ child.on('exit', (code) => {
167
+ if (settled) return;
168
+ settled = true;
169
+ clearTimeout(timer);
170
+ if (code === 0) resolve({ stdout: out, stderr: err });
171
+ else {
172
+ const detail = err.trim() ? `: ${err.trim().slice(0, 200)}` : '';
173
+ reject(new Error(`tailscale ${args.join(' ')} fallo (codigo ${code})${detail}`));
208
174
  }
209
- }
210
- await push(row.id, [{ done: true }]);
211
- }
175
+ });
176
+ });
177
+ }
212
178
 
213
- async function pump() {
214
- const registered = await call('register', {}, { timeoutMs: 20000 });
215
- let cursor = Number(registered.cursor) || 0;
216
- log(`relay: conectado a ${relayUrl} (inbox desde ${cursor})`);
217
- while (!state.stopped) {
218
- let rows;
219
- try {
220
- const batch = await call('poll', { after: cursor, wait: 20 }, { timeoutMs: 40000 });
221
- rows = batch.rows ?? [];
222
- } catch (error) {
223
- if (state.stopped) break;
224
- log(`relay poll: ${error?.message ?? error}`);
225
- await new Promise((r) => setTimeout(r, 3000));
226
- continue;
227
- }
228
- for (const row of rows) {
229
- cursor = Math.max(cursor, Number(row.id) || 0);
230
- try {
231
- await handleRequest(row);
232
- } catch (error) {
233
- log(`relay request ${row.id}: ${error?.message ?? error}`);
234
- try {
235
- await push(row.id, [
236
- {
237
- status: 502,
238
- headers: { 'content-type': 'application/json; charset=utf-8' },
239
- body: Buffer.from(JSON.stringify({ error: `harness inaccesible: ${error?.message ?? error}` })).toString('base64'),
240
- done: true,
241
- },
242
- ]);
243
- } catch {
244
- /* nada */
245
- }
246
- }
247
- }
179
+ /** Interpreta `tailscale status --json`: nombre DNS del nodo e IPv4 del tailnet. */
180
+ export function parseTailscaleStatus(json) {
181
+ const data = typeof json === 'string' ? JSON.parse(json) : json ?? {};
182
+ const dnsName = String(data?.Self?.DNSName ?? '').replace(/\.$/, '');
183
+ const ipv4 = String((data?.Self?.TailscaleIPs ?? []).find((ip) => !String(ip).includes(':')) ?? '');
184
+ return { dnsName, ipv4, connected: Boolean(dnsName) };
185
+ }
186
+
187
+ export async function startTailscaleServe(port, opts = {}) {
188
+ const log = opts.log ?? (() => {});
189
+ let status;
190
+ try {
191
+ status = await runTailscale(['status', '--json']);
192
+ } catch (error) {
193
+ throw new Error(`tailscale no esta disponible: ${error.message}`);
194
+ }
195
+ let parsed;
196
+ try {
197
+ parsed = parseTailscaleStatus(status.stdout);
198
+ } catch {
199
+ throw new Error('no pude interpretar `tailscale status --json`.');
200
+ }
201
+ if (!parsed.connected) {
202
+ throw new Error('Tailscale no esta conectado. Inicia sesion con `tailscale up`.');
203
+ }
204
+ const backend = `http://127.0.0.1:${port}`;
205
+ try {
206
+ await runTailscale(['serve', '--bg', backend], { timeoutMs: 30000 });
207
+ } catch (first) {
208
+ try {
209
+ await runTailscale(['serve', '--bg', String(port)], { timeoutMs: 30000 });
210
+ } catch {
211
+ throw new Error(`no pude publicar con \`tailscale serve\`: ${first.message}`);
248
212
  }
249
213
  }
250
-
251
- pump().catch((error) => {
252
- if (!state.stopped) log(`relay pump: ${error?.message ?? error}`);
253
- });
254
-
214
+ const url = `https://${parsed.dnsName}`;
215
+ log(`tailscale: ${url} -> ${backend}`);
255
216
  return {
256
- url: relayUrl,
257
- provider: 'relay',
217
+ url,
218
+ provider: 'tailscale',
258
219
  pid: 0,
259
220
  child: null,
260
- probeUrl: `${relayUrl}?a=status`,
261
- stop() {
262
- state.stopped = true;
263
- },
221
+ probeUrl: `${url}/api/health`,
222
+ stop: () => runTailscale(['serve', 'reset']).catch(() => {}),
264
223
  };
265
224
  }
266
225
 
226
+ /** Estado de Tailscale (lanza si no esta instalado o no responde). */
227
+ export async function probeTailscale() {
228
+ const { stdout } = await runTailscale(['status', '--json']);
229
+ return parseTailscaleStatus(stdout);
230
+ }
231
+
232
+ /** Quita toda la configuracion de `tailscale serve` de este nodo. */
233
+ export function resetTailscaleServe() {
234
+ return runTailscale(['serve', 'reset'], { timeoutMs: 30000 });
235
+ }
236
+
267
237
  /** Devuelve { url, provider, pid, stop }. provider 'none' no abre nada. */
268
238
  export async function startTunnel(port, provider = 'tunnelmole', opts = {}) {
269
239
  const p = String(provider || 'tunnelmole').toLowerCase();
270
240
  if (p === 'none' || p === 'off' || p === '') return { url: '', provider: 'none', pid: 0, stop: () => {} };
241
+ if (p === 'tailscale' || p === 'ts') return startTailscaleServe(port, opts);
271
242
  if (p === 'tunnelmole' || p === 'tmole') return startTunnelmole(port, opts);
272
- if (p === 'relay') return startRelay(port, opts);
273
243
  throw new Error(`proveedor de tunel no soportado: ${provider}`);
274
244
  }
@@ -48,6 +48,17 @@ test('ensureApp creates app.json with a secret and roundtrips', async () => {
48
48
  assert.equal(readApp().port, 4599);
49
49
  });
50
50
 
51
+ test('readApp migra los proveedores retirados (tunnelmole/relay) a tailscale', async () => {
52
+ const dir = await mkdtemp(path.join(tmpdir(), 'tamnora-home-'));
53
+ setHome(dir);
54
+ for (const provider of ['tunnelmole', 'relay']) {
55
+ const app = ensureApp();
56
+ app.tunnel = { ...app.tunnel, provider };
57
+ writeApp(app);
58
+ assert.equal(readApp().tunnel.provider, 'tailscale', provider);
59
+ }
60
+ });
61
+
51
62
  test('migrateLegacyDb copies the legacy project database to the global home', async () => {
52
63
  const home = await mkdtemp(path.join(tmpdir(), 'tamnora-home-'));
53
64
  const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-cwd-'));
@@ -3,7 +3,6 @@
3
3
  import { renderMarkdown, speechText } from './markdown.js';
4
4
  import { streamSse, followRun } from './runStream.js';
5
5
  import { diffState, pollDelayMs, localState, summarizeArgs } from './reconcile.js';
6
- import { installRelayTransport, showRelayTokenGate } from './relayClient.js';
7
6
 
8
7
  const $ = (sel) => document.querySelector(sel);
9
8
  let csrfToken = '';
@@ -97,30 +96,7 @@ function icon(name, size = 16, className = '') {
97
96
 
98
97
  /* --- HTTP / SSE ---------------------------------------------------------- */
99
98
 
100
- // Tamnora Relay: cuando la consola se sirve desde harness.tamnora.com/console,
101
- // el page define `window.TAMNORA_RELAY = { base }` y todo el trafico /api pasa
102
- // por el mailbox del relay (relaClient). Sin esa variable es el harness local.
103
- const RELAY_CONFIG = window.TAMNORA_RELAY ?? null;
104
- const relayToken = RELAY_CONFIG
105
- ? new URLSearchParams(location.search).get('t') || localStorage.getItem('tamnora-relay-token') || ''
106
- : '';
107
- const relayTransport = RELAY_CONFIG
108
- ? installRelayTransport({ base: RELAY_CONFIG.base, token: relayToken })
109
- : null;
110
-
111
- if (RELAY_CONFIG && !relayTransport.enabled) {
112
- showRelayTokenGate();
113
- throw new Error('[relay] falta el token de acceso');
114
- }
115
-
116
99
  async function api(path, { method = 'GET', body, headers = {} } = {}) {
117
- if (relayTransport?.enabled) {
118
- return relayTransport.request(path, {
119
- method,
120
- body,
121
- headers: method !== 'GET' && csrfToken ? { 'x-csrf': csrfToken, ...headers } : headers,
122
- });
123
- }
124
100
  const init = {};
125
101
  if (body) init['Content-Type'] = 'application/json';
126
102
  if (method !== 'GET' && csrfToken) init['x-csrf'] = csrfToken;
@@ -145,13 +121,10 @@ async function api(path, { method = 'GET', body, headers = {} } = {}) {
145
121
  }
146
122
 
147
123
  const streamPost = (url, body, onEvent, signal) =>
148
- relayTransport?.enabled
149
- ? relayTransport.stream(url, { method: 'POST', body, signal, headers: csrfToken ? { 'x-csrf': csrfToken } : {} }, onEvent)
150
- : streamSse(url, { method: 'POST', body, csrf: csrfToken, signal }, onEvent);
124
+ streamSse(url, { method: 'POST', body, csrf: csrfToken, signal }, onEvent);
151
125
 
152
126
  /** SSE GET de la consola (/api/sessions/:id/stream, /api/events). */
153
- const openStream = (url, opts, onEvent) =>
154
- relayTransport?.enabled ? relayTransport.stream(url, opts, onEvent) : streamSse(url, opts, onEvent);
127
+ const openStream = (url, opts, onEvent) => streamSse(url, opts, onEvent);
155
128
 
156
129
  /* --- localStorage (todas las lecturas/escrituras pasan por aca) ------------ */
157
130
 
@@ -601,7 +574,7 @@ if (logout) {
601
574
  } catch {
602
575
  // igual salimos
603
576
  }
604
- location.href = relayTransport?.enabled ? location.pathname : '/landing';
577
+ location.href = '/landing';
605
578
  });
606
579
  }
607
580
 
@@ -37,7 +37,7 @@
37
37
  </head>
38
38
  <body>
39
39
  <header class="topbar">
40
- <div class="brand"><span class="logo"><img src="/icons/logo.png" alt="Tamnora" /></span> <span class="ver">v0.9.6</span></div>
40
+ <div class="brand"><span class="logo"><img src="/icons/logo.png" alt="Tamnora" /></span> <span class="ver">v0.11.0</span></div>
41
41
  <nav class="tabs">
42
42
  <button class="tab active" data-tab="chat">chat</button>
43
43
  <button class="tab" data-tab="providers">proveedores</button>
@@ -74,6 +74,21 @@ test('localOk exige loopback en remoteAddress y sin tunel activo', async () => {
74
74
  }
75
75
  });
76
76
 
77
+ test('un bridge de tailscale no degrada localOk (red privada por loopback)', async () => {
78
+ const { instance } = await startServer();
79
+ try {
80
+ await writeFile(
81
+ bridgeStatePath(),
82
+ `${JSON.stringify({ pid: process.pid, url: 'https://pc.tailnet.ts.net', provider: 'tailscale', port: 4517 })}\n`
83
+ );
84
+ await new Promise((r) => setTimeout(r, 1100));
85
+ assert.ok(localOk({ socket: { remoteAddress: '127.0.0.1' } }), 'loopback sigue siendo local con tailscale');
86
+ } finally {
87
+ await clearTunnel();
88
+ await instance.close();
89
+ }
90
+ });
91
+
77
92
  test('POST /api/shell responde 403 mientras el tunel esta activo', async () => {
78
93
  const { instance, base } = await startServer();
79
94
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danieltmn/tamnora",
3
- "version": "0.9.6",
3
+ "version": "0.11.0",
4
4
  "description": "Harness local para IAs: agente CLI-first con TUI, consola web y evaluacion. Node puro, sin dependencias.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.25.0",
@@ -1,170 +0,0 @@
1
- // Transporte relay para la consola estatica (harness.tamnora.com/console/).
2
- //
3
- // La misma consola corre en el harness local y en el tamnora del sitio: aqui
4
- // cada llamada a `api()` y cada stream se manda por el mailbox del relay y el
5
- // daemon local (`tamnora bridge --tunnel relay`) la ejecuta contra su propio
6
- // server. El token (?t=...) es la credencial compartida con el mailbox.
7
- //
8
- // Requiere runStream.js (streamSse/delay) que ya carga la consola.
9
-
10
- import { streamSse, delay } from './runStream.js';
11
-
12
- export function installRelayTransport({ base, token }) {
13
- const cleanToken = String(token ?? '').trim();
14
- if (!cleanToken) {
15
- console.warn('[relay] sin token: la consola vera solo la pantalla de login');
16
- }
17
-
18
- const b64decode = (value) =>
19
- new TextDecoder().decode(Uint8Array.from(atob(String(value ?? '')), (c) => c.charCodeAt(0)));
20
-
21
- const relayUrl = (action) => `${base}?a=${action}&t=${encodeURIComponent(cleanToken)}`;
22
-
23
- async function post(action, payload, signal) {
24
- const res = await fetch(relayUrl(action), {
25
- method: 'POST',
26
- headers: { 'Content-Type': 'application/json' },
27
- body: JSON.stringify(payload),
28
- signal,
29
- });
30
- if (!res.ok) {
31
- const error = new Error(`relay HTTP ${res.status}`);
32
- error.status = res.status;
33
- throw error;
34
- }
35
- return res.json();
36
- }
37
-
38
- function applyCookies(headers) {
39
- for (const cookie of headers?.['set-cookie'] ?? []) {
40
- const kv = String(cookie).split(';')[0];
41
- if (kv && kv.includes('=')) document.cookie = `${kv}; Path=/`;
42
- }
43
- }
44
-
45
- /** Request sincrono: PHP espera inline la respuesta completa del mailbox. */
46
- async function request(path, { method = 'GET', body, headers = {} } = {}) {
47
- const envelope = {
48
- method,
49
- path,
50
- headers,
51
- body: body === undefined ? null : JSON.stringify(body),
52
- stream: false,
53
- };
54
- const data = await post('send', envelope);
55
- if (data.error) {
56
- const error = new Error(data.error);
57
- error.status = Number(data.status) || 0;
58
- throw error;
59
- }
60
- applyCookies(data.headers);
61
- const text = b64decode(data.body);
62
- let parsed = null;
63
- try {
64
- parsed = JSON.parse(text);
65
- } catch {
66
- parsed = { raw: text };
67
- }
68
- if (Number(data.status) >= 400) {
69
- const error = new Error(parsed?.error ?? `HTTP ${data.status}`);
70
- error.status = Number(data.status);
71
- throw error;
72
- }
73
- return parsed;
74
- }
75
-
76
- const parseOrRaw = (text) => {
77
- try {
78
- return JSON.parse(text);
79
- } catch {
80
- return { raw: text };
81
- }
82
- };
83
-
84
- /**
85
- * Stream via mailbox: abre el rid y consume `a=stream` (SSE con cursor).
86
- * Si el PHP corta (max_execution_time) reconecta y repite desde el cursor:
87
- * el replay es del mailbox, NO re-ejecuta el request original.
88
- */
89
- async function stream(path, { method = 'POST', body, headers = {}, signal, silenceMs } = {}, onEvent) {
90
- const envelope = {
91
- method,
92
- path,
93
- headers,
94
- body: body === undefined ? null : JSON.stringify(body),
95
- stream: true,
96
- };
97
- const opened = await post('send', envelope, signal);
98
- if (opened.error) {
99
- const error = new Error(opened.error);
100
- error.status = Number(opened.status) || 0;
101
- throw error;
102
- }
103
- const rid = opened.rid;
104
- let cursor = 0;
105
- let attempt = 0;
106
- for (;;) {
107
- if (signal?.aborted) {
108
- const error = new Error('Aborted', 'AbortError');
109
- error.name = 'AbortError';
110
- throw error;
111
- }
112
- try {
113
- let finished = false;
114
- await streamSse(
115
- `${base}?a=stream&t=${encodeURIComponent(cleanToken)}&rid=${encodeURIComponent(rid)}&from=${cursor}`,
116
- { signal, silenceMs },
117
- (event, data, id) => {
118
- if (id != null) cursor = Number(id) || cursor;
119
- if (event === 'chunk') {
120
- const payload = data?.payload ?? {};
121
- if (!payload.done) {
122
- onEvent(payload.ev || 'message', payload.data != null ? parseOrRaw(payload.data) : {}, payload.id ?? null);
123
- }
124
- return;
125
- }
126
- // meta: headers del run o cierre del daemon.
127
- if (data?.payload?.done) finished = true;
128
- else if (data?.payload?.error) {
129
- const error = new Error(data.payload.error);
130
- error.fatalRelay = true;
131
- throw error;
132
- }
133
- }
134
- );
135
- return { finished };
136
- } catch (error) {
137
- if (error?.name === 'AbortError' || error?.fatalRelay) throw error;
138
- attempt += 1;
139
- await delay(Math.min(8000, 1000 * 2 ** (attempt - 1)), signal);
140
- }
141
- }
142
- }
143
-
144
- window.relayTransport = { token: cleanToken, base, request, stream, enabled: Boolean(cleanToken) };
145
- return window.relayTransport;
146
- }
147
-
148
- /** Pantalla minima si no hay token: pide uno y recarga. */
149
- export function showRelayTokenGate() {
150
- document.body.innerHTML = '';
151
- document.body.style.visibility = 'visible';
152
- const form = document.createElement('form');
153
- form.style.cssText = 'max-width:420px;margin:15vh auto;font-family:ui-sans-serif,system-ui;padding:0 16px';
154
- form.innerHTML = `
155
- <h1 style="font-size:20px">Tamnora Console</h1>
156
- <p style="color:#777;font-size:14px">Pegá el token del bridge (o escaneá el QR del <code>tamnora bridge --tunnel relay</code>).</p>
157
- <input name="t" placeholder="token" style="width:100%;padding:10px;font-size:14px" autocomplete="off" />
158
- <button style="margin-top:10px;padding:10px 16px">entrar</button>`;
159
- form.addEventListener('submit', (event) => {
160
- event.preventDefault();
161
- const value = form.elements.t.value.trim();
162
- if (!value) return;
163
- localStorage.setItem('tamnora-relay-token', value);
164
- const params = new URLSearchParams(location.search);
165
- params.delete('t');
166
- location.href = location.pathname + (params.toString() ? `?${params}` : '');
167
- location.reload();
168
- });
169
- document.body.append(form);
170
- }