@mmmbuto/nexuscrew 0.7.7 → 0.8.2

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 (51) hide show
  1. package/README.md +153 -25
  2. package/bin/nexuscrew.js +10 -4
  3. package/frontend/dist/assets/index-COsoJxXQ.css +32 -0
  4. package/frontend/dist/assets/index-Dey9rdY-.js +90 -0
  5. package/frontend/dist/assets/qr-scanner-worker.min-D85Z9gVD.js +98 -0
  6. package/frontend/dist/index.html +2 -2
  7. package/frontend/dist/sw.js +29 -0
  8. package/frontend/dist/version.json +1 -0
  9. package/lib/auth/middleware.js +7 -1
  10. package/lib/auth/token.js +31 -1
  11. package/lib/cli/commands.js +502 -43
  12. package/lib/cli/doctor.js +160 -0
  13. package/lib/cli/fleet-service.js +16 -3
  14. package/lib/cli/init.js +69 -11
  15. package/lib/cli/path.js +24 -0
  16. package/lib/cli/service.js +14 -8
  17. package/lib/cli/url.js +63 -0
  18. package/lib/config.js +5 -0
  19. package/lib/decks/routes.js +81 -0
  20. package/lib/decks/store.js +123 -0
  21. package/lib/files/routes.js +57 -1
  22. package/lib/files/store.js +16 -1
  23. package/lib/fleet/builtin.js +151 -24
  24. package/lib/fleet/definitions.js +26 -0
  25. package/lib/fleet/managed.js +381 -0
  26. package/lib/fleet/routes.js +5 -4
  27. package/lib/mcp/server.js +381 -0
  28. package/lib/nodes/commands.js +411 -0
  29. package/lib/nodes/peering.js +116 -0
  30. package/lib/nodes/store.js +425 -0
  31. package/lib/nodes/tunnel-supervisor.js +102 -0
  32. package/lib/nodes/tunnel.js +315 -0
  33. package/lib/notify/asks.js +150 -0
  34. package/lib/notify/events.js +59 -0
  35. package/lib/notify/notifier.js +37 -0
  36. package/lib/notify/persist.js +73 -0
  37. package/lib/notify/push.js +289 -0
  38. package/lib/notify/routes.js +260 -0
  39. package/lib/proxy/federation.js +217 -0
  40. package/lib/proxy/node-proxy.js +305 -0
  41. package/lib/pty/attach.js +34 -9
  42. package/lib/pty/provider.js +21 -6
  43. package/lib/server.js +291 -14
  44. package/lib/settings/routes.js +685 -0
  45. package/lib/ws/bridge.js +10 -1
  46. package/package.json +8 -2
  47. package/skills/nexuscrew-agent/SKILL.md +83 -0
  48. package/skills/nexuscrew-agent/bin/nc-deliver +23 -0
  49. package/skills/nexuscrew-agent/bin/nc-send +48 -0
  50. package/frontend/dist/assets/index-DUbtTZMj.css +0 -32
  51. package/frontend/dist/assets/index-EVa9bnAC.js +0 -81
@@ -0,0 +1,289 @@
1
+ 'use strict';
2
+ // Web Push per il MCP bridge (design §2b). Chiavi VAPID generate LAZY al primo
3
+ // uso (mai allo startup: i test che creano il server con path isolati non devono
4
+ // scrivere in ~/.nexuscrew) e persistite in <dir>/vapid.json 0600; subscription
5
+ // in <dir>/push.json 0600, dedup per endpoint. Su push fallito 404/410 la
6
+ // subscription viene rimossa (MA non in READONLY: il cleanup e' una scrittura).
7
+ // Il sender e' iniettabile (webpushImpl) cosi' i test non toccano MAI la rete;
8
+ // la chiave PRIVATA non esce da questo modulo.
9
+ //
10
+ // F7 (audit, threat model SSRF): sendNotification fa una richiesta server-side.
11
+ // Ogni endpoint e' https-only, viene risolto sia al subscribe sia immediatamente
12
+ // prima del send, e la richiesta usa un https.Agent con lookup PINNATO agli IP
13
+ // verificati. Cosi' un secondo lookup/DNS rebinding non puo' cambiare destinazione.
14
+ // Redirect non seguiti: web-push usa https.request diretto; una risposta 3xx e'
15
+ // un errore e non viene mai trasformata in una seconda richiesta.
16
+ const path = require('node:path');
17
+ const dns = require('node:dns');
18
+ const https = require('node:https');
19
+ const net = require('node:net');
20
+ const { readJsonSafe, atomicWriteJson } = require('./persist.js');
21
+
22
+ const VAPID_FILE = 'vapid.json';
23
+ const SUBS_FILE = 'push.json';
24
+ // Subject VAPID richiesto dallo standard (URL o mailto); nessun host interno.
25
+ const VAPID_SUBJECT = 'mailto:nexuscrew@example.com';
26
+ const MAX_ENDPOINT_LEN = 2048;
27
+ const MAX_KEY_LEN = 512;
28
+ const DEFAULT_MAX_SUBS = 32;
29
+
30
+ // IPv4 non globale: private, loopback, link-local, carrier-grade NAT,
31
+ // documentation/benchmark, multicast/reserved. Garbage -> true (fail-closed).
32
+ function isNonGlobalV4(ip) {
33
+ const parts = ip.split('.').map(Number);
34
+ if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return true;
35
+ const [a, b] = parts;
36
+ if (a === 0 || a === 10 || a === 127) return true;
37
+ if (a === 100 && b >= 64 && b <= 127) return true;
38
+ if (a === 169 && b === 254) return true;
39
+ if (a === 172 && b >= 16 && b <= 31) return true;
40
+ if (a === 192 && b === 168) return true;
41
+ if (a === 192 && b === 0) return true;
42
+ if (a === 198 && (b === 18 || b === 19)) return true;
43
+ if (a === 198 && b === 51 && parts[2] === 100) return true;
44
+ if (a === 203 && b === 0 && parts[2] === 113) return true;
45
+ if (a >= 224) return true;
46
+ return false;
47
+ }
48
+
49
+ function ipv6Words(input) {
50
+ let ip = String(input || '').toLowerCase();
51
+ const zone = ip.indexOf('%');
52
+ if (zone >= 0) ip = ip.slice(0, zone);
53
+ if (ip.includes('.')) {
54
+ const cut = ip.lastIndexOf(':');
55
+ const v4 = ip.slice(cut + 1);
56
+ if (net.isIP(v4) !== 4) return null;
57
+ const p = v4.split('.').map(Number);
58
+ ip = `${ip.slice(0, cut)}:${((p[0] << 8) | p[1]).toString(16)}:${((p[2] << 8) | p[3]).toString(16)}`;
59
+ }
60
+ const halves = ip.split('::');
61
+ if (halves.length > 2) return null;
62
+ const left = halves[0] ? halves[0].split(':') : [];
63
+ const right = halves.length === 2 && halves[1] ? halves[1].split(':') : [];
64
+ const missing = 8 - left.length - right.length;
65
+ if ((halves.length === 1 && missing !== 0) || missing < 0) return null;
66
+ const raw = halves.length === 2 ? [...left, ...Array(missing).fill('0'), ...right] : left;
67
+ if (raw.length !== 8 || raw.some((x) => !/^[0-9a-f]{1,4}$/.test(x))) return null;
68
+ return raw.map((x) => parseInt(x, 16));
69
+ }
70
+
71
+ function isNonGlobalV6(ip) {
72
+ const w = ipv6Words(ip);
73
+ if (!w) return true;
74
+ // unspecified/loopback e tutto lo spazio IPv4-compatible /96.
75
+ if (w.slice(0, 6).every((x) => x === 0)) return true;
76
+ // IPv4-mapped ::ffff:0:0/96: rifiuto dell'intera famiglia per evitare encoding evasivi.
77
+ if (w.slice(0, 5).every((x) => x === 0) && w[5] === 0xffff) return true;
78
+ if ((w[0] & 0xfe00) === 0xfc00) return true; // ULA fc00::/7
79
+ if ((w[0] & 0xffc0) === 0xfe80) return true; // link-local fe80::/10 (fe80..febf)
80
+ if ((w[0] & 0xffc0) === 0xfec0) return true; // site-local deprecato fec0::/10
81
+ if ((w[0] & 0xff00) === 0xff00) return true; // multicast
82
+ // IPv4-in-IPv6/transizione: rifiuto dell'intero prefisso, anche se l'IPv4
83
+ // incorporato appare pubblico, per evitare rappresentazioni evasive.
84
+ if (w[0] === 0x0064 && w[1] === 0xff9b
85
+ && (w.slice(2, 6).every((x) => x === 0) || w[2] === 0x0001)) return true; // NAT64
86
+ if (w[0] === 0x2002) return true; // 6to4
87
+ if (w[4] === 0 && w[5] === 0x5efe) return true; // ISATAP
88
+ if (w[0] === 0x2001 && (w[1] === 0x0000 || w[1] === 0x0db8)) return true; // special/documentation
89
+ return false;
90
+ }
91
+
92
+ function isNonGlobalIp(address) {
93
+ const family = net.isIP(address);
94
+ if (family === 4) return isNonGlobalV4(address);
95
+ if (family === 6) return isNonGlobalV6(address);
96
+ return true;
97
+ }
98
+
99
+ // Host vietati quando espressi come letterali; gli hostname passano poi dal DNS.
100
+ function isForbiddenHost(hostname) {
101
+ const h = String(hostname || '').toLowerCase();
102
+ if (!h) return true;
103
+ if (h === 'localhost' || h === 'localhost.' || h.endsWith('.localhost')) return true;
104
+ const literal = h.startsWith('[') && h.endsWith(']') ? h.slice(1, -1) : h;
105
+ if (net.isIP(literal)) return isNonGlobalIp(literal);
106
+ return false;
107
+ }
108
+
109
+ // Validazione subscription fail-closed. {ok:true} | {ok:false, error}.
110
+ function validateSubscription(s) {
111
+ if (!s || typeof s !== 'object' || Array.isArray(s)) return { ok: false, error: 'subscription non valida' };
112
+ if (typeof s.endpoint !== 'string' || !s.endpoint) return { ok: false, error: 'endpoint mancante' };
113
+ if (s.endpoint.length > MAX_ENDPOINT_LEN) return { ok: false, error: `endpoint troppo lungo (max ${MAX_ENDPOINT_LEN})` };
114
+ let url;
115
+ try { url = new URL(s.endpoint); } catch (_) { return { ok: false, error: 'endpoint non e\' un URL valido' }; }
116
+ if (url.protocol !== 'https:') return { ok: false, error: 'endpoint deve essere https (i push service usano solo https)' };
117
+ if (url.username || url.password) return { ok: false, error: 'endpoint con credenziali non ammesso' };
118
+ if (isForbiddenHost(url.hostname)) return { ok: false, error: 'endpoint verso host loopback/privato non ammesso' };
119
+ if (!s.keys || typeof s.keys !== 'object' || Array.isArray(s.keys)
120
+ || typeof s.keys.p256dh !== 'string' || !s.keys.p256dh || s.keys.p256dh.length > MAX_KEY_LEN
121
+ || typeof s.keys.auth !== 'string' || !s.keys.auth || s.keys.auth.length > MAX_KEY_LEN) {
122
+ return { ok: false, error: 'subscription.keys non valide (p256dh, auth)' };
123
+ }
124
+ return { ok: true };
125
+ }
126
+
127
+ async function resolvePublicEndpoint(endpoint, lookupImpl = dns.promises.lookup) {
128
+ const url = new URL(endpoint);
129
+ const host = url.hostname.startsWith('[') ? url.hostname.slice(1, -1) : url.hostname;
130
+ if (net.isIP(host)) {
131
+ if (isNonGlobalIp(host)) throw new Error('endpoint verso indirizzo non globale non ammesso');
132
+ return { hostname: host, addresses: [{ address: host, family: net.isIP(host) }] };
133
+ }
134
+ let answers;
135
+ try { answers = await lookupImpl(host, { all: true, verbatim: true }); }
136
+ catch (_) { throw new Error('DNS endpoint non risolvibile'); }
137
+ if (!Array.isArray(answers)) answers = answers ? [answers] : [];
138
+ if (!answers.length || answers.some((a) => !a || isNonGlobalIp(a.address))) {
139
+ throw new Error('DNS endpoint contiene un indirizzo non globale');
140
+ }
141
+ return {
142
+ hostname: host,
143
+ addresses: answers.map((a) => ({ address: a.address, family: a.family || net.isIP(a.address) })),
144
+ };
145
+ }
146
+
147
+ function pinnedAgent(resolved) {
148
+ let next = 0;
149
+ return new https.Agent({
150
+ keepAlive: false,
151
+ maxCachedSessions: 0,
152
+ lookup(hostname, options, callback) {
153
+ if (String(hostname).toLowerCase() !== resolved.hostname.toLowerCase()) {
154
+ callback(new Error('DNS hostname inatteso durante il send'));
155
+ return;
156
+ }
157
+ if (options && options.all) {
158
+ callback(null, resolved.addresses.map((a) => ({ ...a })));
159
+ return;
160
+ }
161
+ const answer = resolved.addresses[next++ % resolved.addresses.length];
162
+ callback(null, answer.address, answer.family);
163
+ },
164
+ });
165
+ }
166
+
167
+ function createPushService(opts = {}) {
168
+ if (!opts.dir) throw new Error('createPushService: dir richiesta');
169
+ const vapidPath = path.join(opts.dir, VAPID_FILE);
170
+ const subsPath = path.join(opts.dir, SUBS_FILE);
171
+ // F3: closure iniettata dal server — in READONLY questo modulo non scrive MAI
172
+ // (niente generazione VAPID, niente cleanup subscription).
173
+ const readonly = typeof opts.readonly === 'function' ? opts.readonly : () => false;
174
+ const lookupImpl = opts.lookupImpl || dns.promises.lookup;
175
+ const maxSubs = Number.isInteger(opts.maxSubs) && opts.maxSubs > 0 ? opts.maxSubs : DEFAULT_MAX_SUBS;
176
+ // Seam test: {generateVAPIDKeys, sendNotification}. Default: npm web-push.
177
+ // require LAZY: il modulo nativo si carica solo se il push viene davvero usato.
178
+ let webpush = opts.webpushImpl || null;
179
+ function impl() {
180
+ if (!webpush) webpush = require('web-push');
181
+ return webpush;
182
+ }
183
+
184
+ // --- VAPID (lazy) ---------------------------------------------------------
185
+ let vapid = null;
186
+ function loadVapid() {
187
+ if (vapid && vapid.publicKey && vapid.privateKey) return vapid;
188
+ const cur = readJsonSafe(vapidPath); // fail-closed su mode/owner/symlink (F4)
189
+ if (typeof cur.publicKey === 'string' && cur.publicKey
190
+ && typeof cur.privateKey === 'string' && cur.privateKey) {
191
+ vapid = { publicKey: cur.publicKey, privateKey: cur.privateKey };
192
+ return vapid;
193
+ }
194
+ // F3: la generazione e' una SCRITTURA — vietata in READONLY (503 esplicito,
195
+ // niente vapid.json fantasma).
196
+ if (readonly()) {
197
+ const e = new Error('READONLY: chiavi VAPID assenti e non generabili (riavvia senza READONLY per il primo setup push)');
198
+ e.status = 503;
199
+ throw e;
200
+ }
201
+ const keys = impl().generateVAPIDKeys();
202
+ vapid = { publicKey: keys.publicKey, privateKey: keys.privateKey };
203
+ atomicWriteJson(vapidPath, vapid);
204
+ return vapid;
205
+ }
206
+
207
+ function vapidPublicKey() { return loadVapid().publicKey; }
208
+
209
+ // --- subscription store ----------------------------------------------------
210
+ function readSubs() {
211
+ const cur = readJsonSafe(subsPath);
212
+ return Array.isArray(cur.subscriptions) ? cur.subscriptions : [];
213
+ }
214
+ function writeSubs(list) { atomicWriteJson(subsPath, { subscriptions: list }); }
215
+
216
+ // Persiste (dedup per endpoint: la nuova sostituisce la vecchia). Cap duro sul
217
+ // numero: un endpoint NUOVO oltre maxSubs viene rifiutato (reason 'cap').
218
+ async function subscribe(subscription) {
219
+ const v = validateSubscription(subscription);
220
+ if (!v.ok) return { ok: false, error: v.error };
221
+ try { await resolvePublicEndpoint(subscription.endpoint, lookupImpl); }
222
+ catch (e) { return { ok: false, error: e.message }; }
223
+ const cur = readSubs();
224
+ const keep = cur.filter((s) => s.endpoint !== subscription.endpoint);
225
+ if (keep.length === cur.length && cur.length >= maxSubs) {
226
+ return { ok: false, reason: 'cap', error: `troppe subscription push (max ${maxSubs}): rimuovine una prima` };
227
+ }
228
+ keep.push({ endpoint: subscription.endpoint, keys: { p256dh: subscription.keys.p256dh, auth: subscription.keys.auth } });
229
+ writeSubs(keep);
230
+ return { ok: true, count: keep.length };
231
+ }
232
+
233
+ function unsubscribe(endpoint) {
234
+ if (typeof endpoint !== 'string' || !endpoint) return { ok: false, error: 'endpoint mancante' };
235
+ const cur = readSubs();
236
+ const keep = cur.filter((s) => s.endpoint !== endpoint);
237
+ if (keep.length !== cur.length) writeSubs(keep);
238
+ return { ok: true, removed: cur.length - keep.length };
239
+ }
240
+
241
+ function count() { return readSubs().length; }
242
+
243
+ // Invia il payload a tutte le subscription. 404/410 (endpoint morto) -> la
244
+ // subscription si rimuove, MA NON in READONLY (F3: riscrivere push.json e' una
245
+ // scrittura persistente; la notify viene comunque consegnata alle vive).
246
+ // Nessuna subscription -> {sent:0} senza generare VAPID (resta lazy davvero).
247
+ async function sendToAll(payload) {
248
+ const subs = readSubs();
249
+ if (subs.length === 0) return { sent: 0, removed: 0 };
250
+ const { publicKey, privateKey } = loadVapid();
251
+ const body = JSON.stringify(payload);
252
+ let sent = 0;
253
+ const dead = new Set();
254
+ for (const sub of subs) {
255
+ let agent;
256
+ try {
257
+ const valid = validateSubscription(sub); // anche store legacy/manomesso: fail-closed al send
258
+ if (!valid.ok) throw new Error(valid.error);
259
+ const resolved = await resolvePublicEndpoint(sub.endpoint, lookupImpl);
260
+ agent = pinnedAgent(resolved);
261
+ const response = await impl().sendNotification(sub, body, {
262
+ vapidDetails: { subject: VAPID_SUBJECT, publicKey, privateKey },
263
+ TTL: 3600,
264
+ agent,
265
+ });
266
+ if (response && Number.isInteger(response.statusCode)
267
+ && (response.statusCode < 200 || response.statusCode > 299)) {
268
+ const e = new Error('redirect/risposta push non ammessa');
269
+ e.statusCode = response.statusCode;
270
+ throw e;
271
+ }
272
+ sent += 1;
273
+ } catch (e) {
274
+ const code = e && e.statusCode;
275
+ if (code === 404 || code === 410) dead.add(sub.endpoint);
276
+ // altri errori: transitori, la subscription resta (best-effort, mai throw)
277
+ } finally { if (agent) agent.destroy(); }
278
+ }
279
+ if (dead.size && !readonly()) writeSubs(subs.filter((s) => !dead.has(s.endpoint)));
280
+ return { sent, removed: dead.size };
281
+ }
282
+
283
+ return { vapidPublicKey, subscribe, unsubscribe, count, sendToAll, validateSubscription };
284
+ }
285
+
286
+ module.exports = {
287
+ createPushService, validateSubscription, isForbiddenHost,
288
+ isNonGlobalIp, resolvePublicEndpoint, pinnedAgent,
289
+ };
@@ -0,0 +1,260 @@
1
+ 'use strict';
2
+ // Route del MCP bridge (design §2): notify, web-push, asks. Montate dentro il
3
+ // router /api gia' dietro requireToken (server.js) — qui restano solo:
4
+ // - READONLY come FLOOR (F3 audit): sono gated 403 TUTTI i mutanti — answer
5
+ // (scrittura PTY via paste), push subscribe/unsubscribe (push.json) e la
6
+ // CREAZIONE di ask (persiste asks.json e genererebbe domande non
7
+ // risponibili dallo stesso server). L'UNICA eccezione dichiarata e' la
8
+ // notify: canale informativo inbound cella→operatore, effimero (broadcast SSE +
9
+ // push senza persistenza; anche il cleanup delle subscription morte e'
10
+ // sospeso in READONLY, vedi lib/notify/push.js). Le GET restano lettura
11
+ // pura: /push/vapid in READONLY non genera chiavi (503 se assenti).
12
+ // - rate-limit (F1 audit): il campo `session` e' dichiarato dal chiamante e
13
+ // NON e' un confine di sicurezza — il limite che conta e' GLOBALE per
14
+ // principal/token (un Bearer = un'installazione); il bucket per-sessione
15
+ // resta come fairness tra celle oneste. Stessa coppia di limiti sulla
16
+ // creazione ask (F5).
17
+ // - validazione input strict fail-closed (schema chiuso per ogni body).
18
+ // Il paste della risposta riusa ESATTAMENTE pasteToSession (bracketed literal,
19
+ // niente Invio, control char rifiutati): qui si sanifica il testo PRIMA.
20
+ const express = require('express');
21
+ const { isValidSession } = require('../files/store.js');
22
+
23
+ const NOTIFY_KEYS = new Set(['title', 'body', 'urgency', 'session']);
24
+ const ASK_KEYS = new Set(['question', 'options', 'session']);
25
+ const RATE_MAX = 6;
26
+ const RATE_WINDOW_MS = 60 * 1000;
27
+ const RATE_MAX_BUCKETS = 64;
28
+ const MAX_TITLE = 200;
29
+ const MAX_BODY = 2000;
30
+ // Il paste tmux accetta max 4096 char: prefisso `[human reply · ask#xxxxxxxx] `
31
+ // (~32 char) + testo -> cap prudente sul testo.
32
+ const MAX_ANSWER = 3900;
33
+ const MAX_REPLY_LABEL = 48;
34
+
35
+ // Sliding window in-memory per chiave. La mappa ha un cap duro (F1): entry
36
+ // scadute potate a ogni giro, poi evizione LRU deterministica (ordine di
37
+ // iterazione della Map = ordine di ultimo uso, re-insert ad ogni allow).
38
+ // NB: l'evizione azzera il conteggio del bucket evitto — per questo il cap
39
+ // per-chiave NON e' il confine di sicurezza: quello e' il bucket GLOBALE
40
+ // (chiave fissa, mai evitto perche' sempre re-inserito per ultimo).
41
+ function createRateLimiter({ max = RATE_MAX, windowMs = RATE_WINDOW_MS, maxBuckets = RATE_MAX_BUCKETS, now = Date.now } = {}) {
42
+ const hits = new Map(); // key -> [timestamps]
43
+ function allow(key) {
44
+ const t = now();
45
+ const list = (hits.get(key) || []).filter((x) => t - x < windowMs);
46
+ const allowed = list.length < max;
47
+ if (allowed) list.push(t);
48
+ hits.delete(key); hits.set(key, list); // re-insert: la Map resta in ordine LRU
49
+ // prune deterministico: prima le entry con finestra scaduta...
50
+ for (const [k, l] of hits) {
51
+ if (k !== key && (l.length === 0 || t - l[l.length - 1] >= windowMs)) hits.delete(k);
52
+ }
53
+ // ...poi cap duro LRU (la meno recente e' la prima in iterazione).
54
+ while (hits.size > maxBuckets) hits.delete(hits.keys().next().value);
55
+ return allowed;
56
+ }
57
+ return { allow, size: () => hits.size };
58
+ }
59
+
60
+ // Coppia di limiti F1/F5: globale per token (confine di sicurezza) + per
61
+ // sessione (fairness). Una richiesta oltre-limite consuma comunque il budget
62
+ // globale: anche lo spam rifiutato e' attivita' del principal.
63
+ function createSenderLimiter(rateCfg = {}) {
64
+ const perSession = createRateLimiter(rateCfg);
65
+ const global = createRateLimiter({
66
+ max: rateCfg.globalMax || rateCfg.max || RATE_MAX,
67
+ windowMs: rateCfg.windowMs || RATE_WINDOW_MS,
68
+ maxBuckets: 2,
69
+ });
70
+ return (sender) => {
71
+ const g = global.allow('*'); // valutato SEMPRE (niente short-circuit nascosto)
72
+ const s = perSession.allow(sender);
73
+ return g && s;
74
+ };
75
+ }
76
+
77
+ // Control char (0x00-0x1f, 0x7f) -> spazio: il paste li rifiuta a monte, e una
78
+ // risposta multiriga della UI deve comunque arrivare come UNA riga senza Invio.
79
+ function sanitizePasteText(text) {
80
+ let out = '';
81
+ for (let i = 0; i < text.length; i += 1) {
82
+ const c = text.charCodeAt(i);
83
+ out += (c <= 0x1f || c === 0x7f) ? ' ' : text[i];
84
+ }
85
+ return out.trim();
86
+ }
87
+
88
+ function replyLabel(cfg) {
89
+ const clean = sanitizePasteText(String((cfg && cfg.replyLabel) || 'human')).slice(0, MAX_REPLY_LABEL).trim();
90
+ return clean || 'human';
91
+ }
92
+
93
+ function notifyRoutes({ cfg, notifier, push, asks, paste, sessionExists }) {
94
+ const r = express.Router();
95
+ const json = express.json({ limit: '16kb' });
96
+
97
+ const readonly = () => (cfg.readonlyDefault === true || process.env.NEXUSCREW_READONLY === '1');
98
+ const mutGate = (_req, res, next) => {
99
+ if (readonly()) return res.status(403).json({ error: 'READONLY: mutazione bloccata' });
100
+ next();
101
+ };
102
+ const allowNotify = createSenderLimiter(cfg.notifyRate || {});
103
+ const allowAsk = createSenderLimiter(cfg.askRate || cfg.notifyRate || {});
104
+
105
+ // --- POST /notify — broadcast UI + web-push -------------------------------
106
+ r.post('/notify', json, async (req, res) => {
107
+ try {
108
+ const b = req.body;
109
+ if (!b || typeof b !== 'object' || Array.isArray(b)) {
110
+ return res.status(400).json({ error: 'body deve essere un oggetto JSON' });
111
+ }
112
+ for (const k of Object.keys(b)) {
113
+ if (!NOTIFY_KEYS.has(k)) return res.status(400).json({ error: `chiave non ammessa: "${k}" (schema: title, body?, urgency?, session?)` });
114
+ }
115
+ if (typeof b.title !== 'string' || !b.title.trim()) {
116
+ return res.status(400).json({ error: 'title deve essere una stringa non vuota' });
117
+ }
118
+ if (b.title.length > MAX_TITLE) return res.status(400).json({ error: `title troppo lungo (max ${MAX_TITLE})` });
119
+ if (b.body !== undefined && (typeof b.body !== 'string' || b.body.length > MAX_BODY)) {
120
+ return res.status(400).json({ error: `body deve essere una stringa (max ${MAX_BODY})` });
121
+ }
122
+ if (b.urgency !== undefined && b.urgency !== 'normal' && b.urgency !== 'high') {
123
+ return res.status(400).json({ error: 'urgency deve essere "normal" o "high"' });
124
+ }
125
+ if (b.session !== undefined && !isValidSession(b.session)) {
126
+ return res.status(400).json({ error: 'session non valida' });
127
+ }
128
+ const sender = b.session || 'unknown';
129
+ if (!allowNotify(sender)) {
130
+ return res.status(429).json({ error: 'rate limit notify superato (limite globale per token + per sessione)' });
131
+ }
132
+ const delivered = await notifier.emit({
133
+ title: b.title.trim(), body: b.body, urgency: b.urgency, session: b.session,
134
+ });
135
+ res.json({ delivered });
136
+ } catch (e) { res.status(500).json({ error: String(e.message || e) }); }
137
+ });
138
+
139
+ // --- web-push --------------------------------------------------------------
140
+ // GET lettura pura; in READONLY push.vapidPublicKey() NON genera chiavi e
141
+ // segnala 503 (e.status) se assenti — F3.
142
+ r.get('/push/vapid', (_req, res) => {
143
+ try { res.json({ publicKey: push.vapidPublicKey() }); }
144
+ catch (e) { res.status(e.status || 500).json({ error: String(e.message || e) }); }
145
+ });
146
+
147
+ r.post('/push/subscribe', mutGate, json, async (req, res) => {
148
+ try {
149
+ const sub = req.body && req.body.subscription;
150
+ const out = await push.subscribe(sub);
151
+ // F7: cap sul numero di subscription -> 429 (quota), input invalido -> 400.
152
+ if (!out.ok) return res.status(out.reason === 'cap' ? 429 : 400).json({ error: out.error });
153
+ res.json({ subscribed: true, count: out.count });
154
+ } catch (e) { res.status(500).json({ error: String(e.message || e) }); }
155
+ });
156
+
157
+ r.delete('/push/subscribe', mutGate, json, (req, res) => {
158
+ try {
159
+ const out = push.unsubscribe(req.body && req.body.endpoint);
160
+ if (!out.ok) return res.status(400).json({ error: out.error });
161
+ res.json({ removed: out.removed });
162
+ } catch (e) { res.status(500).json({ error: String(e.message || e) }); }
163
+ });
164
+
165
+ // --- asks ------------------------------------------------------------------
166
+ // F3: gated READONLY (mutGate) — crea stato durevole (asks.json) e domande
167
+ // che lo stesso server vieterebbe di rispondere. F5: rate-limit creazione
168
+ // (globale per token + per sessione) + cap duro dello store -> 429.
169
+ r.post('/asks', mutGate, json, async (req, res) => {
170
+ try {
171
+ const b = req.body;
172
+ if (!b || typeof b !== 'object' || Array.isArray(b)) {
173
+ return res.status(400).json({ error: 'body deve essere un oggetto JSON' });
174
+ }
175
+ for (const k of Object.keys(b)) {
176
+ if (!ASK_KEYS.has(k)) return res.status(400).json({ error: `chiave non ammessa: "${k}" (schema: question, options?, session)` });
177
+ }
178
+ // session obbligatoria E viva: la risposta va incollata li' — un ask senza
179
+ // recapito verificabile e' fail-closed subito, non al momento dell'answer.
180
+ if (!isValidSession(b.session)) return res.status(400).json({ error: 'session non valida' });
181
+ if (!sessionExists(b.session)) return res.status(404).json({ error: 'sessione tmux inesistente' });
182
+ // Validazione del contenuto PRIMA del rate check: gli input invalidi (400)
183
+ // non consumano budget; il rate scatta solo su richieste ben formate.
184
+ const v = asks.validate({ question: b.question, options: b.options });
185
+ if (!v.ok) return res.status(400).json({ error: v.error });
186
+ if (!allowAsk(b.session)) {
187
+ return res.status(429).json({ error: 'rate limit ask superato (limite globale per token + per sessione)' });
188
+ }
189
+ const out = asks.create({ question: b.question, options: b.options, session: b.session });
190
+ if (!out.ok) return res.status(out.reason === 'cap' ? 429 : 400).json({ error: out.error });
191
+ const ask = out.ask;
192
+ // Frame dedicato per le UI aperte (card/badge live, senza aspettare il poll)…
193
+ notifier.emitRaw({ type: 'ask', ask });
194
+ // …e ogni ask emette anche notify (UI+push, urgency high) con deep-link.
195
+ await notifier.emit({
196
+ title: `domanda da ${ask.session}`,
197
+ body: ask.question,
198
+ urgency: 'high',
199
+ session: ask.session,
200
+ url: `/#ask=${ask.id}`,
201
+ });
202
+ res.status(201).json({ id: ask.id });
203
+ } catch (e) { res.status(500).json({ error: String(e.message || e) }); }
204
+ });
205
+
206
+ r.get('/asks', (req, res) => {
207
+ try { res.json({ asks: asks.list({ open: String(req.query.open || '') === '1' }) }); }
208
+ catch (e) { res.status(500).json({ error: String(e.message || e) }); }
209
+ });
210
+
211
+ // Answer: READONLY floor (il paste e' una scrittura PTY). F2 (audit): il
212
+ // ciclo e' claim atomico (open -> answering, sincrono, PRIMA dell'await del
213
+ // paste) -> paste -> commit su successo / release su fallimento. Una sola
214
+ // richiesta concorrente vince; le altre vedono 409. Paste fallito -> 502 e
215
+ // l'ask torna open (ri-risponibile dopo il resurrect della cella).
216
+ r.post('/asks/:id/answer', mutGate, json, async (req, res) => {
217
+ try {
218
+ const id = String(req.params.id || '');
219
+ // Validazione del testo PRIMA del claim: nessun claim da rilasciare su 400.
220
+ const raw = req.body && req.body.text;
221
+ if (typeof raw !== 'string') return res.status(400).json({ error: 'text deve essere una stringa' });
222
+ const text = sanitizePasteText(raw).slice(0, MAX_ANSWER);
223
+ if (!text && asks.get(id)) return res.status(400).json({ error: 'text vuoto dopo la sanificazione' });
224
+ const claim = asks.claim(id);
225
+ if (!claim.ok) {
226
+ if (claim.reason === 'unknown') return res.status(404).json({ error: 'ask inesistente' });
227
+ if (claim.reason === 'answering') return res.status(409).json({ error: 'risposta gia\' in corso da un\'altra richiesta' });
228
+ return res.status(409).json({ error: 'ask gia\' risposto' });
229
+ }
230
+ let pasted = false;
231
+ try {
232
+ pasted = await paste(claim.ask.session, `[${replyLabel(cfg)} reply · ask#${id}] ${text}`);
233
+ } catch (_) { pasted = false; }
234
+ if (!pasted) {
235
+ asks.release(id); // rollback: l'ask resta open e contendibile
236
+ return res.status(502).json({ error: `paste fallito: sessione "${claim.ask.session}" non raggiungibile` });
237
+ }
238
+ asks.commit(id, text);
239
+ // Le altre UI aperte tolgono la card/badge senza aspettare il poll.
240
+ notifier.emitRaw({ type: 'ask-answered', id });
241
+ res.json({ answered: true, id });
242
+ } catch (e) { res.status(500).json({ error: String(e.message || e) }); }
243
+ });
244
+
245
+ // Body JSON malformato (express.json) -> 400 con causa, mai stack trace.
246
+ // eslint-disable-next-line no-unused-vars
247
+ r.use((err, _req, res, _next) => {
248
+ if (err && err.type === 'entity.parse.failed') {
249
+ return res.status(400).json({ error: 'body JSON non valido' });
250
+ }
251
+ if (err && err.type === 'entity.too.large') {
252
+ return res.status(400).json({ error: 'body troppo grande (limite 16kb)' });
253
+ }
254
+ res.status((err && err.status) || 500).json({ error: String((err && err.message) || err) });
255
+ });
256
+
257
+ return r;
258
+ }
259
+
260
+ module.exports = { notifyRoutes, createRateLimiter, sanitizePasteText, replyLabel };