/* * NIS2 Agile — Avatar di prodotto: demo-mode runtime (product-demo-protocol v1.0.1). * * Si attiva SOLO con ?demo= nell'URL. NIS2 è multi-page: la navigazione tra * viste = cambio pagina con ?demo preservato; ad ogni pagina il runtime si re-inizializza * e ristabilisce il canale postMessage col widget AgileHub (iframe_origin). * * Canale: parent (widget AgileHub @ dimostrazione.agile.software) ⇄ questa SPA (iframe). * - inbound (widget→SPA): navigate, highlight, scroll_to, trigger_action, open_tab, * set_period, show_overlay_text, reset_demo_state, end_tour * - outbound (SPA→widget): ready, spa_state_changed, step_animation_done, user_clicked, * error, requested_help, cta_clicked * Sicurezza: verifica ev.origin, protocol, HMAC (chiave canale), idempotency message_id (~30s), * whitelist azioni (demo-selectors.js). Read-only: le scritture sono bloccate server-side * dal guard DEMO_READ_ONLY (BaseController::applyDemoGuard). * * ⚠️ HMAC CANONICAL: la derivazione della CHIAVE canale è da contratto * SHA-256(demo_jwt + ":" + seed + ":demo-control-channel"); la canonicalizzazione del * MESSAGGIO per l'HMAC NON è fissata nello standard → vedi canonicalize() (TO-RECONCILE * con AgileHub al collaudo §8.6). Se enforce e il canonical non combacia, i messaggi * vengono scartati: in tal caso allinea canonicalize() alla forma del parser AgileHub. */ (function (w, d) { 'use strict'; // Origin del widget/parent AgileHub. Allowlist robusta a entrambe le topologie: // widget sull'hub (dimostrazione.agile.software) o sul sottodominio prodotto. Da // confermare al collaudo E2E; intanto accettiamo entrambi (e l'origin reale viene // bloccato comunque all'allowlist). var ALLOWED_ORIGINS = ['https://dimostrazione.agile.software', 'https://nis2.dimostrazione.agile.software']; function parentOrigin() { try { var ao = w.location.ancestorOrigins; if (ao && ao.length && ALLOWED_ORIGINS.indexOf(ao[0]) >= 0) return ao[0]; } catch (e) {} return state.parentOrigin || ALLOWED_ORIGINS[0]; } var PROTOCOL = 'agilehub.product-demo.v1'; // Canale = RUNTIME AVATAR (postMessage widget→SPA), NON external/v1. Chiarimento AgileHub // 2026-06-13: il runtime avatar NON usa HMAC per-messaggio (server-to-server = X-Internal-Key, // già usato su /api/demo/reset-dataset). Confine di sicurezza del postMessage = verifica ev.origin // (same-origin su dimostrazione.agile.software). HMAC verify resta nel codice, flag-gated: // se al collaudo risultasse che il widget firma, rimettere true + allineare canonicalize(). var HMAC_ENFORCE = false; var SEL = w.NIS2_DEMO_SELECTORS || null; var qs = new URLSearchParams(w.location.search); var sessionId = qs.get('demo'); if (!sessionId) return; // non in demo-mode → no-op var state = { jwt: null, seed: null, channelKeyRaw: null, seen: {}, ready: false, parentOrigin: null }; // ── util crypto ────────────────────────────────────────────────────────── function enc(s) { return new TextEncoder().encode(s); } function toHex(buf) { return Array.prototype.map.call(new Uint8Array(buf), function (b) { return ('0' + b.toString(16)).slice(-2); }).join(''); } function sha256(str) { return crypto.subtle.digest('SHA-256', enc(str)); } // chiave canale (raw 32 byte) = SHA-256(demo_jwt + ":" + seed + ":demo-control-channel") function deriveKey() { return sha256(state.jwt + ':' + state.seed + ':demo-control-channel').then(function (buf) { state.channelKeyRaw = buf; }); } // canonicalizzazione del messaggio per l'HMAC — TO-RECONCILE con AgileHub. function canonicalize(msg) { return [msg.protocol, msg.type, msg.message_id, msg.ts, JSON.stringify(msg.payload || {})].join('|'); } function verifyHmac(msg) { if (!HMAC_ENFORCE) return Promise.resolve(true); if (!msg.hmac || !state.channelKeyRaw) return Promise.resolve(false); return crypto.subtle.importKey('raw', state.channelKeyRaw, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']) .then(function (key) { return crypto.subtle.sign('HMAC', key, enc(canonicalize(msg))); }) .then(function (sig) { return toHex(sig).toLowerCase() === String(msg.hmac).toLowerCase(); }) .catch(function () { return false; }); } // ── outbound ───────────────────────────────────────────────────────────── function send(type, payload) { try { w.parent.postMessage({ protocol: PROTOCOL, type: type, payload: payload || {}, message_id: 'spa-' + Date.now() + '-' + Math.floor(performance.now()), ts: new Date().toISOString() }, parentOrigin()); } catch (e) { /* noop */ } } function currentView() { var p = w.location.pathname.replace(/^\//, '').replace(/\.html$/, ''); return p || 'index'; } // ── UI helpers ─────────────────────────────────────────────────────────── function banner() { if (d.getElementById('nis2-demo-banner')) return; var b = d.createElement('div'); b.id = 'nis2-demo-banner'; b.innerHTML = 'Modalità dimostrazione — sola lettura · NIS2 AgileARIA ti guida'; d.body.appendChild(b); d.body.classList.add('nis2-demo-on', 'nis2-demo-readonly'); } function toast(t) { var el = d.getElementById('nis2-demo-toast'); if (!el) { el = d.createElement('div'); el.id = 'nis2-demo-toast'; d.body.appendChild(el); } el.textContent = t; el.style.display = 'block'; setTimeout(function () { el.style.display = 'none'; }, 3500); } var hlEls = []; function clearHl() { hlEls.forEach(function (e) { e.classList.remove('nis2-demo-hl', 'pulse-blue', 'halo', 'outline'); }); hlEls = []; } function highlight(selector, style, duration) { clearHl(); var el; try { el = d.querySelector(selector); } catch (e) { el = null; } if (!el) { send('error', { code: 'SELECTOR_NOT_FOUND', selector: selector }); return; } el.classList.add('nis2-demo-hl', style || 'pulse-blue'); hlEls.push(el); try { el.scrollIntoView({ behavior: 'smooth', block: 'center' }); } catch (e) {} setTimeout(function () { clearHl(); send('step_animation_done', { selector: selector }); }, duration || 4000); } function withDemo(url) { return url + (url.indexOf('?') < 0 ? '?' : '&') + 'demo=' + encodeURIComponent(sessionId); } function gotoView(view) { var url = SEL && SEL.viewUrl(view); if (!url) { send('error', { code: 'UNKNOWN_VIEW', view: view }); return; } if (currentView() === view) { send('spa_state_changed', { view: view }); return; } w.location.href = withDemo(url); // ?demo preservato → re-init sulla nuova pagina } function runAction(action) { if (!SEL || !SEL.isAllowedAction(action)) { send('error', { code: 'ACTION_NOT_WHITELISTED', action: action }); return; } var a = SEL.action(action); var el = null; if (a.type === 'anchor') { try { el = d.querySelector('[data-demo-action="' + action + '"]'); } catch (e) {} } else if (a.selector) { try { el = d.querySelector(a.selector); } catch (e) {} } if (!el) { send('error', { code: 'ACTION_ANCHOR_MISSING', action: action }); return; } el.click(); send('user_clicked', { action: action }); } // ── inbound dispatch ───────────────────────────────────────────────────── function dispatch(msg) { var p = msg.payload || {}; switch (msg.type) { case 'navigate': gotoView(p.view); break; case 'highlight': highlight(p.selector, p.style, p.duration_ms); break; case 'scroll_to': try { var e = d.querySelector(p.selector); if (e) e.scrollIntoView({ behavior: 'smooth', block: 'center' }); } catch (x) {} break; case 'trigger_action': runAction(p.action); break; case 'open_tab': runAction('open-tab-' + p.tab); break; case 'show_overlay_text':toast(p.text || ''); break; case 'set_period': /* NIS2 non ha periodi globali: no-op informativo */ send('spa_state_changed', { view: currentView(), note: 'set_period non applicabile' }); break; case 'reset_demo_state': w.location.href = withDemo(SEL.viewUrl('dashboard')); break; case 'end_tour': clearHl(); toast('Demo terminata.'); break; default: /* tipi non gestiti: ignora */ break; } } function onMessage(ev) { if (ALLOWED_ORIGINS.indexOf(ev.origin) < 0) return; state.parentOrigin = ev.origin; // origin reale del widget (per outbound mirati) var msg = ev.data; if (!msg || msg.protocol !== PROTOCOL || !msg.type) return; verifyHmac(msg).then(function (ok) { if (!ok) { send('error', { code: 'HMAC_INVALID', type: msg.type }); return; } if (msg.message_id) { if (state.seen[msg.message_id]) return; state.seen[msg.message_id] = Date.now(); } dispatch(msg); }); } // ── init ───────────────────────────────────────────────────────────────── function loadCredentials() { state.seed = sessionStorage.getItem('demo_hmac_seed'); state.jwt = sessionStorage.getItem('demo_jwt'); if (state.jwt && state.seed) return Promise.resolve(); // fallback: recupera dal backend (il seed resta quello scritto dal widget) return fetch('/api/demo/credentials?session_id=' + encodeURIComponent(sessionId)) .then(function (r) { return r.json(); }) .then(function (j) { var x = (j && j.data) || j || {}; state.jwt = state.jwt || x.demo_jwt; if (x.hmac_key_seed && !state.seed) state.seed = x.hmac_key_seed; }); } function boot() { // HMAC_ENFORCE=false (canale runtime-avatar origin-verified): seed/chiave NON necessari. // Cross-origin il widget non può scrivere il seed in sessionStorage e /credentials non lo // restituisce → NON bloccare il boot sul seed, altrimenti il runtime (banner/listener) non parte. var pre = HMAC_ENFORCE ? loadCredentials().then(function () { if (!state.jwt || !state.seed) throw new Error('demo creds mancanti'); return deriveKey(); }) : Promise.resolve(); pre .then(function () { banner(); w.addEventListener('message', onMessage, false); state.ready = true; send('ready', { view: currentView(), session_id: sessionId, ua: navigator.userAgent }); send('spa_state_changed', { view: currentView() }); // segnala i click utente su CTA marcate d.addEventListener('click', function (e) { var cta = e.target.closest && e.target.closest('[data-demo-cta]'); if (cta) send('cta_clicked', { cta: cta.getAttribute('data-demo-cta') }); }, true); }) .catch(function (err) { send('error', { code: 'DEMO_INIT_FAILED', message: String(err && err.message || err) }); }); } if (d.readyState === 'loading') d.addEventListener('DOMContentLoaded', boot); else boot(); })(window, document);