diff --git a/application/controllers/DemoController.php b/application/controllers/DemoController.php new file mode 100644 index 0000000..278e9fa --- /dev/null +++ b/application/controllers/DemoController.php @@ -0,0 +1,244 @@ +clientIp(); + if (!$this->rateLimit($ip)) { + header('Retry-After: 60'); + $this->jsonError('Troppe richieste demo, riprova tra poco', 429, 'RATE_LIMIT_EXCEEDED'); + } + + $language = in_array($this->getParam('language'), ['it', 'en'], true) ? $this->getParam('language') : 'it'; + $variant = in_array($this->getParam('tour_variant'), ['full', 'express'], true) ? $this->getParam('tour_variant') : 'full'; + + $sessionId = 'demo-' . $this->uuid4(); + $hmacKeySeed = base64_encode(random_bytes(32)); // 44 char base64 standard + $orgId = self::DEMO_ORG_ID; + $expiresTs = time() + self::JWT_TTL_SEC; + + $demoJwt = $this->generateJWT(0, [ + 'scope' => 'demo:read-only', + 'org_id' => $orgId, + 'demo_session_id' => $sessionId, + 'exp' => $expiresTs, + ]); + + // ip_hash server-side (anti-spoof): sha256(ip + daily salt) + $ipHash = hash('sha256', $ip . '|' . date('Y-m-d') . '|' . JWT_SECRET); + + Database::insert('demo_sessions', [ + 'session_id' => $sessionId, + 'organization_id' => $orgId, + 'hmac_key_seed' => $hmacKeySeed, + 'language' => $language, + 'tour_variant' => $variant, + 'presentation_mode' => 'realtime', + 'referrer_url' => mb_substr((string) $this->getParam('referrer_url', ''), 0, 512) ?: null, + 'user_agent' => mb_substr((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 512) ?: null, + 'ip_hash' => $ipHash, + 'expires_at' => date('Y-m-d H:i:s', $expiresTs), + ]); + + $this->jsonSuccess([ + 'session_id' => $sessionId, + 'demo_jwt' => $demoJwt, + 'company_id' => $orgId, // alias di compatibilità standard + 'organization_id' => $orgId, + 'spa_iframe_url' => self::DEMO_BASE . '/?demo=' . $sessionId, + 'manifest_url' => self::DEMO_BASE . '/api/demo/manifest?session_id=' . $sessionId, + 'credentials_url' => self::DEMO_BASE . '/api/demo/credentials?session_id=' . $sessionId, + 'expires_at' => gmdate('Y-m-d\TH:i:s\Z', $expiresTs), + 'hmac_key_seed' => $hmacKeySeed, + 'presentation_mode' => 'realtime', + ], 'Sessione demo creata', 201); + } + + // ── 2) GET /api/demo/manifest?session_id= ──────────────────────────────── + public function manifest(): void + { + $session = $this->resolveSession(); + if (!$session) { + $this->jsonError('Sessione demo non valida o scaduta', 401, 'DEMO_SESSION_INVALID'); + } + + $lang = in_array($this->getParam('language'), ['it', 'en'], true) + ? $this->getParam('language') : ($session['language'] ?? 'it'); + + $file = PUBLIC_PATH . '/demo/nis2-tour-2026.json'; + if (is_file($file)) { + $manifest = json_decode((string) file_get_contents($file), true) ?: []; + $manifest['session_id'] = $session['session_id']; + $manifest['language'] = $lang; + $this->jsonSuccess($manifest); + } + + // Placeholder finché il manifest reale non è pubblicato (Fase 1, step successivo) + $this->jsonSuccess([ + 'tour_id' => 'demo:nis2-tour-2026', + 'tour_version' => '0.1', + 'session_id' => $session['session_id'], + 'language' => $lang, + 'tour_variant' => $session['tour_variant'] ?? 'full', + 'presentation_mode' => 'realtime', + 'persona_key' => 'ARIA_SUPPORT_NIS2', + 'control_protocol' => 'agilehub.product-demo.v1', + 'iframe_origin' => self::DEMO_BASE, + 'steps' => [], + 'fallback_static' => ['image_url' => null, 'audio_url_it' => null, 'audio_url_en' => null], + '_note' => 'manifest placeholder — steps in arrivo (Fase 1)', + ]); + } + + // ── 3) POST /api/demo/event ────────────────────────────────────────────── + public function event(): void + { + $session = $this->resolveSession(); + if (!$session) { + $this->jsonError('Sessione demo non valida o scaduta', 401, 'DEMO_SESSION_INVALID'); + } + + $eventType = (string) $this->getParam('event_type', ''); + $allowed = ['step_completed','step_skipped','tour_paused','tour_aborted','free_question_asked','cta_clicked','presentation_mode_chosen','error_encountered']; + if (!in_array($eventType, $allowed, true)) { + $this->jsonError('event_type non valido', 400, 'INVALID_EVENT_TYPE'); + } + + $stepId = $this->getParam('step_id'); + $tsIso = mb_substr((string) $this->getParam('client_timestamp_iso', gmdate('Y-m-d\TH:i:s\Z')), 0, 40); + $meta = $this->getParam('metadata'); + + // Idempotente: UNIQUE (session_id, event_type, step_id, client_timestamp_iso) + try { + Database::insert('demo_events', [ + 'session_id' => $session['session_id'], + 'event_type' => $eventType, + 'step_id' => $stepId !== null ? (int) $stepId : null, + 'metadata' => $meta !== null ? json_encode($meta, JSON_UNESCAPED_UNICODE) : null, + 'client_timestamp_iso' => $tsIso, + ]); + } catch (Throwable $e) { + // duplicato (replay idempotente) → ignora silenziosamente + } + + http_response_code(204); + exit; + } + + // ── 4) GET /api/demo/credentials?session_id= ───────────────────────────── + public function credentials(): void + { + $sessionId = (string) $this->getParam('session_id', ''); + $session = $sessionId !== '' ? $this->loadSession($sessionId) : null; + if (!$session) { + $this->jsonError('Sessione demo non trovata o scaduta', 404, 'DEMO_SESSION_NOT_FOUND'); + } + + $expiresTs = strtotime($session['expires_at']); + $demoJwt = $this->generateJWT(0, [ + 'scope' => 'demo:read-only', + 'org_id' => (int) $session['organization_id'], + 'demo_session_id' => $session['session_id'], + 'exp' => $expiresTs, + ]); + + $this->jsonSuccess([ + 'demo_jwt' => $demoJwt, + 'company_id' => (int) $session['organization_id'], + 'organization_id' => (int) $session['organization_id'], + 'session_id' => $session['session_id'], + 'expires_at' => gmdate('Y-m-d\TH:i:s\Z', $expiresTs), + ]); + } + + // ── 5) POST /api/demo/reset-dataset ────────────────────────────────────── + public function resetDataset(): void + { + $key = $_SERVER['HTTP_X_INTERNAL_KEY'] ?? ''; + $expected = getenv('INTERNAL_DEMO_KEY') ?: (getenv('INTERNAL_EMAIL_KEY') ?: ''); + if ($expected === '' || !hash_equals($expected, $key)) { + $this->jsonError('Non autorizzato', 401, 'UNAUTHORIZED'); + } + + // TODO Fase 1 (increment dataset): DELETE + re-seed org demo 996001 da seed idempotente. + $this->jsonSuccess([ + 'reset' => 'pending_implementation', + 'company_ids' => [self::DEMO_ORG_ID], + 'note' => 'Logica di reset dataset demo in arrivo (seed isolato org 996001).', + ], 'Reset demo (stub)'); + } + + // ═══ Helpers ═════════════════════════════════════════════════════════════ + + /** Risolve la sessione da Bearer demo_jwt (preferito) o ?session_id=. */ + private function resolveSession(): ?array + { + $token = $this->getBearerToken(); + if ($token) { + $payload = $this->verifyJWT($token); + if ($payload && ($payload['scope'] ?? '') === 'demo:read-only' && !empty($payload['demo_session_id'])) { + return $this->loadSession((string) $payload['demo_session_id']); + } + } + $sid = (string) $this->getParam('session_id', ''); + return $sid !== '' ? $this->loadSession($sid) : null; + } + + private function loadSession(string $sessionId): ?array + { + $s = Database::fetchOne('SELECT * FROM demo_sessions WHERE session_id = ?', [$sessionId]); + if (!$s) return null; + if (strtotime($s['expires_at']) < time()) return null; // scaduta + return $s; + } + + /** Rate-limit file-based: 5/min + 50/h per IP. true = consentito. */ + private function rateLimit(string $ip): bool + { + if (!is_dir(self::RL_DIR)) @mkdir(self::RL_DIR, 0700, true); + $f = self::RL_DIR . '/' . md5($ip) . '.json'; + $now = time(); + $hits = is_file($f) ? (json_decode((string) file_get_contents($f), true) ?: []) : []; + $hits = array_values(array_filter($hits, fn($t) => $t > $now - 3600)); + $lastMin = count(array_filter($hits, fn($t) => $t > $now - 60)); + if ($lastMin >= 5 || count($hits) >= 50) return false; + $hits[] = $now; + @file_put_contents($f, json_encode($hits), LOCK_EX); + return true; + } + + private function uuid4(): string + { + $b = random_bytes(16); + $b[6] = chr((ord($b[6]) & 0x0f) | 0x40); + $b[8] = chr((ord($b[8]) & 0x3f) | 0x80); + return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($b), 4)); + } + + private function clientIp(): string + { + $xff = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? ''; + if ($xff !== '') return trim(explode(',', $xff)[0]); + return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'; + } +} diff --git a/docs/DEMO_AVATAR_NIS2.md b/docs/DEMO_AVATAR_NIS2.md new file mode 100644 index 0000000..b5c4bac --- /dev/null +++ b/docs/DEMO_AVATAR_NIS2.md @@ -0,0 +1,68 @@ +# Avatar di prodotto NIS2 — Demo + Formazione (standard product-demo-complete) + +> Stato: **Fase 1 (DEMO) in costruzione**. Owner prodotto: NIS2. Owner persona/voce/collaudo: AgileHub. +> Persona già LIVE: `ARIA_SUPPORT_NIS2` (id=4). Voce ARIA riusata (niente consenso voce). +> Modalità **A** (prodotto live: l'avatar pilota la SPA reale). + +## Decisioni prese (2026-06-12) +1. **Scopo**: ENTRAMBI, in 2 fasi → Fase 1 DEMO (vetrina prospect ~10 step) poi Fase 2 FORMAZIONE (riusa i mattoni + curriculum/quiz/attestato). +2. **Completamento formazione**: **esame con soglia** (quiz valutato da LLM-giudice, attestato solo se ≥ soglia, es. 80%). Coerente col gate G7. +3. **Dominio same-origin**: **dimostrazione.agile.software** (come ALLTAX/TRPG): landing serve la SPA NIS2 in iframe same-origin con proxy `/api/*` → backend NIS2. + +## Range riservato demo (NIS2) +`organization_id` **996000–996999** (per standard §7.1). Org demo isolata: **996001** (ente fittizio). Separata dalle org reali e dalle 2 golden-demo (#151/#152). + +## Deliverable lato prodotto — DEMO (punti 1–5 del brief) +- [x] **Migration** `docs/sql/040_demo_sessions.sql` — tabelle `demo_sessions` + `demo_events`. +- [x] **DemoController** (`application/controllers/DemoController.php`) — 5 endpoint (vedi sotto). +- [x] **Rotte** `demo` registrate in `public/index.php`. +- [ ] **Guardia read-only + demo-JWT in `requireAuth`** (BaseController) — *step isolato, delicato: tocca l'auth di ogni endpoint*. Design sotto §Read-only. +- [ ] **Dataset demo isolato** (org 996001 + dati finti) + reset. +- [ ] **Frontend**: `public/js/demo-mode.js`, `public/js/demo-selectors.js`, `public/css/demo.css`, hook `?demo=` nell'entry-point. +- [ ] **Manifest** `public/demo/nis2-tour-2026.json` (~10 step IT; EN dopo). +- [ ] **Mappe** `docs/mappa-logica/*.md` (1 per vista) + `SELETTORI_DEMO.md` + anchor `data-demo-action` + id stabili nel DOM. + +## Deliverable IN PIÙ — FORMAZIONE (punti 6–7, dopo la demo) +- [ ] Mappa-logica **COMPLETA** (tutte le viste, non solo le 10 della demo). +- [ ] **Tassonomia corso** (moduli → lezioni → argomenti, prerequisiti, durata). *Formato JSON da definire (lo standard non lo fissa).* +- [ ] (opz.) banca esercizi/domande per modulo + sandbox scrivibile isolata. + +## Endpoint (route shape scelta) +Per non combattere il router NIS2 (path-param numerici), il `session_id` (stringa `demo-`) viaggia in **query/body**, non in path. Le URL esatte che userà il widget sono quelle che ritorno io in `session-start` (`manifest_url`, `credentials_url`), quindi è conforme. + +| Endpoint | Metodo | Auth | Note | +|---|---|---|---| +| `/api/demo/session-start` | POST | pubblico (rate-limit 5/min, 50/h per IP) | ritorna session_id, demo_jwt (scope=demo:read-only, org_id 996001, TTL 30m), hmac_key_seed (base64 44ch), spa_iframe_url, manifest_url, credentials_url, expires_at, presentation_mode | +| `/api/demo/manifest?session_id=` | GET | Bearer demo_jwt o session_id | ritorna il manifest tour | +| `/api/demo/event` | POST | Bearer demo_jwt | telemetria step (idempotente) | +| `/api/demo/credentials?session_id=` | GET | session valida (same-origin/bearer) | ritorna demo_jwt+org+expires (per bootstrap SPA in iframe) | +| `/api/demo/reset-dataset` | POST | `X-Internal-Key` | reset dataset demo (cron 0 3 * * *) | + +## Read-only enforcement (design — da implementare con cura) +Il demo_jwt è messo dalla SPA in `localStorage.jwt` e usato sulle API reali (dashboard, risks, ...). Quindi `BaseController::requireAuth()` deve **riconoscere** il demo_jwt: +- se `payload.scope === 'demo:read-only'`: **bypassa** il check `jti`/`active_sessions`, imposta org corrente = `payload.org_id` (forzato nel range 996000–996999), e **blocca ogni verbo di scrittura** (POST/PUT/PATCH/DELETE) sulle API app → `403 DEMO_READ_ONLY` (eccetto gli endpoint `/api/demo/*`); +- range guard: demo_jwt fuori range → `403 DEMO_JWT_CANNOT_ACCESS_PRODUCTION`; JWT di produzione su org in range demo → `403 PRODUCTION_JWT_CANNOT_ACCESS_DEMO`. +- `getCurrentOrgId()` in demo ignora `X-Organization-Id` e usa l'org del JWT. + +## Punti di integrazione DA CONFERMARE con AgileHub (gap dello standard) +1. **Canonical HMAC del canale postMessage**: lo standard ha **2 versioni divergenti** di `signDemoMessage`/`verifyDemoMessage` (ordine canonical `{message_id,...}` vs `{message_id,session_id,...}`, channelKey CryptoKey vs raw). Il `demo-mode.js` di NIS2 deve combaciare **byte-per-byte** col widget AgileHub deployato → **confermare quale canonical usa il widget live** prima dello smoke. +2. **Nome storage seed**: `bootstrapDemoAuth` legge `sessionStorage['demo_hmac_seed']` ma il campo API è `hmac_key_seed` → allineare chi scrive il seed in sessionStorage (widget) e chi lo legge (SPA). +3. **Same-origin / proxy** su `dimostrazione.agile.software` → `/api/*` verso backend NIS2 (config gateway lato AgileHub) + CSP che permetta l'iframe. +4. **Catalog/binding tour**: lato AgileHub servono `provider_resources_catalog (demo_tour)` + `agent_resource_bindings (demo_tour)` o il bootstrap meet-page aborta "no matching tour". +5. **Allineamento 3-way** DOM ↔ `demo-selectors.js` ↔ `SELETTORI_DEMO.md`: ogni cambio selettore → avvisare AgileHub per re-ingest RAG (dedup per content_hash → erasure-prima). + +## Collaudo (definizione di "fatto") +DEMO: G1 selezione ≥95% PASS · **G2 parlato ≥8/10 e 0 leak** · G3 vista ≤5% falsi positivi · G4 sicurezza/GDPR verde. +FORMAZIONE (+): G5 copertura (nessun buco) · G6 accuratezza didattica · G7 validità assessment. +> "Fatto" solo quando passa il collaudo (mostra la cosa giusta, dice cose vere) — non quando l'avatar parla. + +## Tour proposto (~10 step, sulla V2 reale) +1 Dashboard (score+scadenze) · 2 Assessment (gap Art.21) · 3 Rischi (matrice+trattamenti) · 4 Incidenti Art.23 (24h/72h) · 5 Policy · 6 Supply chain · 7 Asset (relevance GV.OC-04) · 8 Report · 9 Normative/ACN · 10 ARIA (assistente AI). + +## Riferimenti +- Standard protocollo: `agile-services/docs/STANDARD_PRODUCT_DEMO_PROTOCOL.md` (v1.0.1) +- Standard metodo+collaudo: `agile-services/docs/STANDARD_PRODUCT_DEMO_COMPLETE.md` +- Requisiti NIS2: `agile-services/docs/OUTGOING_TO_NIS2_2026_05_29_demo_avatar_requirements.md` +- How-to mappe: `agile-services/docs/PLAYBOOK_DEMO_AVATAR_MAPPE.md` +- Esempio dev: `agile-services/docs/SPEC_ALLTAX_DEMO_AVATAR_PER_DEV.md` +- Formazione: `agile-services/docs/DESIGN_AVATAR_FORMAZIONE.md` diff --git a/docs/sql/040_demo_sessions.sql b/docs/sql/040_demo_sessions.sql new file mode 100644 index 0000000..2350aa5 --- /dev/null +++ b/docs/sql/040_demo_sessions.sql @@ -0,0 +1,31 @@ +-- NIS2 Agile — Migration 040: Demo Avatar (product-demo-protocol v1.0.1) +-- Tabelle additive per la modalità demo guidata dall'avatar di prodotto. +-- Range org demo riservato: 996000-996999 (org demo isolata = 996001). +-- Idempotente: CREATE TABLE IF NOT EXISTS. + +CREATE TABLE IF NOT EXISTS demo_sessions ( + session_id VARCHAR(64) NOT NULL PRIMARY KEY, -- 'demo-' + organization_id INT NOT NULL, -- org demo (996000-996999) + hmac_key_seed VARCHAR(64) NOT NULL, -- base64 standard 44 char + language VARCHAR(5) NOT NULL DEFAULT 'it', + tour_variant VARCHAR(16) NOT NULL DEFAULT 'full', + presentation_mode VARCHAR(16) NOT NULL DEFAULT 'realtime', + referrer_url VARCHAR(512) NULL, + user_agent VARCHAR(512) NULL, + ip_hash VARCHAR(64) NULL, -- sha256(ip+DAILY_SALT), server-side + expires_at DATETIME NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_demo_sessions_expires (expires_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS demo_events ( + id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + session_id VARCHAR(64) NOT NULL, + event_type VARCHAR(40) NOT NULL, -- step_completed | step_skipped | ... + step_id INT NULL, + metadata JSON NULL, + client_timestamp_iso VARCHAR(40) NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_demo_event_idem (session_id, event_type, step_id, client_timestamp_iso), + INDEX idx_demo_events_session (session_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/public/index.php b/public/index.php index afef73e..8ee3b2a 100644 --- a/public/index.php +++ b/public/index.php @@ -113,6 +113,7 @@ $controllerMap = [ 'ai' => 'AiController', // Assistente AI ARIA (askWithRag + fonti certe) 'branding' => 'BrandingController', // White-label firm (Fase 5 / G16) 'supplier-portal' => 'SupplierPortalController', // Portale fornitore Fase 3 (auth OTP/magic-link separata) + 'demo' => 'DemoController', // Avatar di prodotto — demo guidata (product-demo-protocol v1.0.1) ]; if (!isset($controllerMap[$controllerName])) { @@ -541,6 +542,17 @@ $actionMap = [ 'GET:current' => 'getCurrent', 'PUT:index' => 'update', ], + + // ── DemoController — Avatar di prodotto (product-demo-protocol v1.0.1) ── + // NB: il router camelCasa il segmento URL (session-start → sessionStart), quindi le chiavi + // dei multi-parola vanno in camelCase. URL esterni: /api/demo/session-start, /reset-dataset. + 'demo' => [ + 'POST:sessionStart' => 'sessionStart', + 'GET:manifest' => 'manifest', + 'POST:event' => 'event', + 'GET:credentials' => 'credentials', + 'POST:resetDataset' => 'resetDataset', + ], ]; // ═══════════════════════════════════════════════════════════════════════════