diff --git a/application/controllers/InviteController.php b/application/controllers/InviteController.php index 80869b4..074c2a6 100644 --- a/application/controllers/InviteController.php +++ b/application/controllers/InviteController.php @@ -6,15 +6,20 @@ * Gli inviti vengono generati da NIS2 Admin (o via API admin), * distribuiti dall'e-commerce, usati da lg231 e altri sistemi Agile. * - * Endpoints admin (richiedono JWT super_admin): - * POST /api/invites → genera invito - * GET /api/invites → lista inviti - * GET /api/invites/{id} → dettaglio invito - * DELETE /api/invites/{id} → revoca invito + * Endpoints admin (richiedono JWT super_admin OPPURE API Key con scope admin:licenses): + * POST /api/invites/create → genera invito + * GET /api/invites/list → lista inviti + * GET /api/invites/{id} → dettaglio invito + * DELETE /api/invites/{id} → revoca invito * POST /api/invites/{id}/regenerate → rigenera token mantenendo config * * Endpoints pubblici (nessuna auth): - * GET /api/invites/validate/{token} → valida invito (preview piano, scadenza) + * GET /api/invites/validate?token= → valida invito (preview piano, scadenza) + * + * Auth da sistemi esterni (es. mktg-agile): + * Header: X-API-Key: nis2_xxxx... + * Scope richiesto: admin:licenses (oppure read:all per sola lettura) + * Genera la chiave in: NIS2 Agile → Settings → API Keys */ require_once __DIR__ . '/BaseController.php'; @@ -53,8 +58,7 @@ class InviteController extends BaseController */ public function create(): void { - $this->requireAuth(); - $this->requireRole(['super_admin']); + $this->requireLicenseAuth(writeRequired: true); $body = $this->getBody(); $plan = in_array($body['plan'] ?? '', ['essentials','professional','enterprise']) @@ -135,8 +139,7 @@ class InviteController extends BaseController */ public function index(): void { - $this->requireAuth(); - $this->requireRole(['super_admin']); + $this->requireLicenseAuth(writeRequired: false); $status = $_GET['status'] ?? null; // pending/used/expired/revoked $channel = $_GET['channel'] ?? null; @@ -184,8 +187,7 @@ class InviteController extends BaseController */ public function show(int $id = 0): void { - $this->requireAuth(); - $this->requireRole(['super_admin']); + $this->requireLicenseAuth(writeRequired: false); $row = Database::fetchOne('SELECT * FROM invites WHERE id=? LIMIT 1', [$id]); if (!$row) $this->jsonError('Invito non trovato', 404, 'NOT_FOUND'); @@ -206,8 +208,7 @@ class InviteController extends BaseController */ public function revoke(int $id = 0): void { - $this->requireAuth(); - $this->requireRole(['super_admin']); + $this->requireLicenseAuth(writeRequired: true); $row = Database::fetchOne('SELECT id, status FROM invites WHERE id=? LIMIT 1', [$id]); if (!$row) $this->jsonError('Invito non trovato', 404, 'NOT_FOUND'); if ($row['status'] === 'used') $this->jsonError('Invito già usato — non revocabile', 422, 'ALREADY_USED'); @@ -224,8 +225,7 @@ class InviteController extends BaseController */ public function regenerate(int $id = 0): void { - $this->requireAuth(); - $this->requireRole(['super_admin']); + $this->requireLicenseAuth(writeRequired: true); $row = Database::fetchOne('SELECT * FROM invites WHERE id=? LIMIT 1', [$id]); if (!$row) $this->jsonError('Invito non trovato', 404, 'NOT_FOUND'); if ($row['status'] === 'used') $this->jsonError('Invito già usato — impossibile rigenerare', 422, 'ALREADY_USED'); @@ -376,6 +376,62 @@ class InviteController extends BaseController return $features[$plan] ?? $features['professional']; } + // ══════════════════════════════════════════════════════════════════════ + // AUTH HELPER — JWT super_admin OR API Key con scope admin:licenses + // ══════════════════════════════════════════════════════════════════════ + + /** + * Accetta: + * a) JWT super_admin (header Authorization: Bearer eyJ...) + * b) API Key con scope admin:licenses o read:all (header X-API-Key: nis2_...) + * + * Se nessuno dei due è valido → 401. + * Se $writeRequired=true e la chiave ha solo read:all → 403. + */ + private function requireLicenseAuth(bool $writeRequired = false): void + { + // ── tenta API Key ──────────────────────────────────────────────── + $rawKey = $_SERVER['HTTP_X_API_KEY'] + ?? (isset($_SERVER['HTTP_AUTHORIZATION']) && str_starts_with($_SERVER['HTTP_AUTHORIZATION'], 'ApiKey ') + ? substr($_SERVER['HTTP_AUTHORIZATION'], 7) : null); + + if ($rawKey) { + if (!str_starts_with($rawKey, 'nis2_')) { + $this->jsonError('Formato API Key non valido', 401, 'INVALID_API_KEY_FORMAT'); + } + $hash = hash('sha256', $rawKey); + $key = Database::fetchOne( + 'SELECT id, organization_id, scopes, is_active, expires_at + FROM api_keys WHERE key_hash=? LIMIT 1', + [$hash] + ); + if (!$key || !$key['is_active']) { + $this->jsonError('API Key non valida o disattivata', 401, 'INVALID_API_KEY'); + } + if ($key['expires_at'] && strtotime($key['expires_at']) < time()) { + $this->jsonError('API Key scaduta', 401, 'EXPIRED_API_KEY'); + } + $scopes = json_decode($key['scopes'] ?? '[]', true); + $hasAdmin = in_array('admin:licenses', $scopes, true); + $hasRead = in_array('read:all', $scopes, true) || in_array('admin:licenses', $scopes, true); + + if (!$hasRead) { + $this->jsonError('Scope insufficiente (richiesto: admin:licenses o read:all)', 403, 'INSUFFICIENT_SCOPE'); + } + if ($writeRequired && !$hasAdmin) { + $this->jsonError('Scope insufficiente per scrittura (richiesto: admin:licenses)', 403, 'WRITE_SCOPE_REQUIRED'); + } + + // Aggiorna last_used_at + Database::execute('UPDATE api_keys SET last_used_at=NOW() WHERE id=?', [$key['id']]); + return; + } + + // ── fallback: JWT super_admin ──────────────────────────────────── + $this->requireAuth(); + $this->requireRole(['super_admin']); + } + // ── helper ─────────────────────────────────────────────────────────── private function getBody(): array { diff --git a/docs/integration/nis2-license-api.postman.json b/docs/integration/nis2-license-api.postman.json new file mode 100644 index 0000000..27b9eea --- /dev/null +++ b/docs/integration/nis2-license-api.postman.json @@ -0,0 +1,259 @@ +{ + "info": { + "name": "NIS2 Agile — License API", + "description": "API per la gestione licenze NIS2 Agile da sistemi esterni (mktg-agile, e-commerce, partner).\n\nAutenticazione: API Key con scope `admin:licenses`\nHeader: `X-API-Key: nis2_xxxx`\n\nPer ottenere una chiave: login su https://nis2.agile.software → licenseExt.html → login → Settings → API Keys → scope: admin:licenses", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_postman_id": "nis2-license-api-v1" + }, + "variable": [ + { "key": "base_url", "value": "https://nis2.agile.software/api", "type": "string" }, + { "key": "api_key", "value": "nis2_LA_TUA_CHIAVE_QUI", "type": "string" }, + { "key": "jwt", "value": "", "type": "string" }, + { "key": "invite_id","value": "1", "type": "string" } + ], + "item": [ + { + "name": "0. Auth — Ottieni JWT (alternativa all'API Key)", + "item": [ + { + "name": "POST /auth/login", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "const d = pm.response.json();", + "if (d.success) {", + " pm.collectionVariables.set('jwt', d.data.access_token);", + " console.log('JWT salvato:', d.data.access_token.substring(0,30) + '...');", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "body": { + "mode": "raw", + "raw": "{\n \"email\": \"cristiano.benassati@gmail.com\",\n \"password\": \"Silvia1978!@\"\n}" + }, + "url": { "raw": "{{base_url}}/auth/login", "host": ["{{base_url}}"], "path": ["auth","login"] }, + "description": "Ottieni JWT super_admin (valido 2h). Alternativa all'API Key per test manuali.\nIl JWT viene salvato automaticamente nella variabile {{jwt}}." + } + } + ] + }, + { + "name": "1. Crea Licenza", + "item": [ + { + "name": "POST /invites/create — Licenza singola professional 12m", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "const d = pm.response.json();", + "pm.test('Status 201', () => pm.response.to.have.status(201));", + "pm.test('Contiene token', () => pm.expect(d.data.invites[0].token).to.match(/^inv_/));", + "if (d.success && d.data.invites.length) {", + " pm.collectionVariables.set('invite_id', d.data.invites[0].id);", + " console.log('TOKEN (salva subito!):', d.data.invites[0].token);", + " console.log('URL onboarding:', d.data.invites[0].invite_url);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" }, + { "key": "X-API-Key", "value": "{{api_key}}" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"plan\": \"professional\",\n \"duration_months\": 12,\n \"invite_expires_days\": 30,\n \"max_uses\": 1,\n \"max_users_per_org\": 10,\n \"label\": \"NIS2 Professional — Ordine MKT-2026-001\",\n \"channel\": \"ecommerce\",\n \"issued_to\": \"cliente@azienda.it\",\n \"reseller_name\": \"mktg-agile S.r.l.\",\n \"price_eur\": 990.00,\n \"notes\": \"Campagna Q1 2026\"\n}" + }, + "url": { "raw": "{{base_url}}/invites/create", "host": ["{{base_url}}"], "path": ["invites","create"] }, + "description": "Crea una licenza singola Professional 12 mesi.\n\nRESPONSE (201):\n- `invites[].token` — inv_xxx... → SALVARE SUBITO, non recuperabile\n- `invites[].invite_url` — URL per attivare da browser\n- `invites[].expires_at` — scadenza invito (entro cui attivare)\n- `warning` — promemoria sicurezza token" + } + }, + { + "name": "POST /invites/create — Batch 5 licenze Essentials", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" }, + { "key": "X-API-Key", "value": "{{api_key}}" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"plan\": \"essentials\",\n \"duration_months\": 6,\n \"invite_expires_days\": 60,\n \"max_uses\": 1,\n \"max_users_per_org\": 3,\n \"quantity\": 5,\n \"label\": \"Essentials 6m — Bundle Reseller Q1\",\n \"channel\": \"reseller\",\n \"reseller_name\": \"Partner XYZ\",\n \"price_eur\": 490.00,\n \"notes\": \"Batch 5 licenze per partner XYZ\"\n}" + }, + "url": { "raw": "{{base_url}}/invites/create", "host": ["{{base_url}}"], "path": ["invites","create"] }, + "description": "Genera 5 token in un'unica chiamata (quantity=5).\nRisposta contiene array `invites` con 5 elementi, ciascuno con token unico." + } + }, + { + "name": "POST /invites/create — Enterprise con restrizione P.IVA", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" }, + { "key": "X-API-Key", "value": "{{api_key}}" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"plan\": \"enterprise\",\n \"duration_months\": 24,\n \"invite_expires_days\": 14,\n \"max_uses\": 1,\n \"label\": \"Enterprise 24m — Cliente Acme S.r.l.\",\n \"channel\": \"direct\",\n \"issued_to\": \"ciso@acme.it\",\n \"restrict_vat\": \"02345678901\",\n \"restrict_email\": \"ciso@acme.it\",\n \"price_eur\": 4800.00,\n \"notes\": \"Contratto enterprise diretto — approvato da DG\"\n}" + }, + "url": { "raw": "{{base_url}}/invites/create", "host": ["{{base_url}}"], "path": ["invites","create"] }, + "description": "Licenza Enterprise riservata a una specifica P.IVA e email admin.\nSe usata da P.IVA diversa → errore 403 INVITE_VAT_MISMATCH." + } + } + ] + }, + { + "name": "2. Lista Licenze", + "item": [ + { + "name": "GET /invites/list — tutte", + "request": { + "method": "GET", + "header": [{ "key": "X-API-Key", "value": "{{api_key}}" }], + "url": { + "raw": "{{base_url}}/invites/list?limit=50", + "host": ["{{base_url}}"], + "path": ["invites","list"], + "query": [ + { "key": "limit", "value": "50" }, + { "key": "offset", "value": "0", "disabled": true } + ] + }, + "description": "Lista tutte le licenze. Filtrabile per status e channel.\nCampi risposta: id, token_prefix, plan, status, used_count, max_uses, max_users_per_org, price_eur, reseller_name, expires_at, channel, label, issued_to, used_by_org_id." + } + }, + { + "name": "GET /invites/list — solo pending (attive)", + "request": { + "method": "GET", + "header": [{ "key": "X-API-Key", "value": "{{api_key}}" }], + "url": { + "raw": "{{base_url}}/invites/list?status=pending&limit=100", + "host": ["{{base_url}}"], + "path": ["invites","list"], + "query": [ + { "key": "status", "value": "pending" }, + { "key": "limit", "value": "100" } + ] + } + } + }, + { + "name": "GET /invites/list — per canale ecommerce", + "request": { + "method": "GET", + "header": [{ "key": "X-API-Key", "value": "{{api_key}}" }], + "url": { + "raw": "{{base_url}}/invites/list?channel=ecommerce&status=pending", + "host": ["{{base_url}}"], + "path": ["invites","list"], + "query": [ + { "key": "channel", "value": "ecommerce" }, + { "key": "status", "value": "pending" } + ] + } + } + }, + { + "name": "GET /invites/{id} — dettaglio singolo", + "request": { + "method": "GET", + "header": [{ "key": "X-API-Key", "value": "{{api_key}}" }], + "url": { + "raw": "{{base_url}}/invites/{{invite_id}}", + "host": ["{{base_url}}"], + "path": ["invites","{{invite_id}}"] + }, + "description": "Dettaglio completo. Se usata, include used_by_org con name/sector/nis2_entity_type." + } + } + ] + }, + { + "name": "3. Revoca / Rigenera", + "item": [ + { + "name": "DELETE /invites/{id} — revoca", + "request": { + "method": "DELETE", + "header": [{ "key": "X-API-Key", "value": "{{api_key}}" }], + "url": { + "raw": "{{base_url}}/invites/{{invite_id}}", + "host": ["{{base_url}}"], + "path": ["invites","{{invite_id}}"] + }, + "description": "Revoca la licenza. Operazione non reversibile.\nLa licenza passa a status=revoked. Eventuali aziende già provisionate rimangono attive (il provisioning è già avvenuto).\nNon si può revocare una licenza già usata (status=used)." + } + }, + { + "name": "POST /invites/{id}/regenerate — nuovo token", + "request": { + "method": "POST", + "header": [{ "key": "X-API-Key", "value": "{{api_key}}" }], + "url": { + "raw": "{{base_url}}/invites/{{invite_id}}/regenerate", + "host": ["{{base_url}}"], + "path": ["invites","{{invite_id}}","regenerate"] + }, + "description": "Genera un nuovo token invalidando il vecchio. Utile se il token è stato inviato per errore.\nRisposta: { id, token, token_prefix, warning }. Il nuovo token è visibile una sola volta." + } + } + ] + }, + { + "name": "4. Validazione pubblica (no auth)", + "item": [ + { + "name": "GET /invites/validate?token= — anteprima invito", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}/invites/validate?token=inv_INSERISCI_TOKEN_QUI", + "host": ["{{base_url}}"], + "path": ["invites","validate"], + "query": [{ "key": "token", "value": "inv_INSERISCI_TOKEN_QUI" }] + }, + "description": "Endpoint pubblico — nessuna auth richiesta.\nUsato da lg231 prima del provisioning e dalla pagina onboarding per mostrare l'anteprima piano.\nRisposta: { valid, plan, duration_months, expires_at, remaining_uses, max_users_per_org, plan_features[] }" + } + } + ] + }, + { + "name": "5. Provisioning (per lg231 / e-commerce)", + "item": [ + { + "name": "POST /services/provision — attivazione con invite_token", + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "body": { + "mode": "raw", + "raw": "{\n \"invite_token\": \"inv_INSERISCI_TOKEN_RICEVUTO\",\n \"company\": {\n \"ragione_sociale\": \"Acme S.r.l.\",\n \"partita_iva\": \"02345678901\",\n \"ateco_code\": \"62.01.00\",\n \"sector\": \"ict\"\n },\n \"admin\": {\n \"email\": \"ciso@acme.it\",\n \"first_name\": \"Marco\",\n \"last_name\": \"Rossi\"\n },\n \"caller\": {\n \"system\": \"mktg-agile\",\n \"callback_url\": \"https://mktg.agile.software/api/webhooks/nis2-provisioned\"\n }\n}" + }, + "url": { + "raw": "{{base_url}}/services/provision", + "host": ["{{base_url}}"], + "path": ["services","provision"] + }, + "description": "Attiva automaticamente una licenza usando il token ricevuto.\nNON richiede X-API-Key — l'invite_token è l'auth.\nRisposta: org_id, api_key, access_token (JWT 2h), temp_password, license_expires_at, dashboard_url." + } + } + ] + } + ] +} diff --git a/public/mktg-api-doc.html b/public/mktg-api-doc.html new file mode 100644 index 0000000..8dc30b1 --- /dev/null +++ b/public/mktg-api-doc.html @@ -0,0 +1,365 @@ + + +
+ + +Documentazione completa per l'integrazione da mktg-agile e sistemi e-commerce.
+ Risponde alle domande del team marketing su endpoint, autenticazione e risposta API.
// Header richiesti:
+Content-Type: application/json
+X-API-Key: nis2_LA_TUA_CHIAVE ← vedi sezione 2
+
+// Body (tutti i campi disponibili):
+{
+ "plan": "professional", // essentials | professional | enterprise
+ "duration_months": 12, // durata licenza dopo attivazione
+ "invite_expires_days": 30, // giorni per attivare l'invito
+ "max_uses": 1, // n. aziende che possono usarlo (1=singolo)
+ "max_users_per_org": 10, // max utenti per azienda (ometti = illimitato)
+ "label": "NIS2 Pro — Ordine MKT-001", // etichetta interna
+ "channel": "ecommerce", // ecommerce|lg231|direct|reseller|manual
+ "issued_to": "cliente@co.it", // email o nome destinatario
+ "reseller_name": "Partner XYZ", // riferimento reseller (solo vostro)
+ "price_eur": 990.00, // prezzo applicato (solo riferimento)
+ "notes": "Campagna Q1 2026", // note interne marketing
+ "quantity": 1, // genera N token in batch (default 1, max 50)
+ // OPZIONALE — restrizioni di sicurezza:
+ "restrict_vat": "02345678901", // solo questa P.IVA può usarlo
+ "restrict_email": "ciso@co.it" // solo questo admin può usarlo
+}
+
+ plan, label. Tutti gli altri sono opzionali con valori di default ragionevoli.
+
+ Ogni chiamata da mktg-agile deve includere l'header X-API-Key con una chiave dedicata.
+
mktg-agile integration → Scope: admin:licensesnis2_xxxx... — visibile una sola voltaNIS2_API_KEY=nis2_xxxx...# Chiamata da mktg-agile (curl): +curl -X POST https://nis2.agile.software/api/invites/create \ + -H "X-API-Key: nis2_LA_TUA_CHIAVE" \ + -H "Content-Type: application/json" \ + -d '{"plan":"professional","duration_months":12,"label":"Test"}'+ +
| Scope | Tipo | Permessi |
|---|---|---|
admin:licenses CONSIGLIATO | Lettura + Scrittura | Crea, lista, revoca, rigenera licenze |
read:all SOLA LETTURA | Solo lettura | Lista e dettaglio licenze, nessuna modifica |
POST /api/auth/login con le credenziali super_admin e usa il token restituito
+ come Authorization: Bearer eyJ.... Valido 2 ore.
+ HTTP 201. JSON. Array invites — uno per token generato.
{
+ "success": true,
+ "data": {
+ "invites": [
+ {
+ "id": 42,
+ "token": "inv_a1b2c3d4e5f6...", ← SALVA SUBITO — non recuperabile
+ "token_prefix": "inv_a1b2c3...", ← riferimento visivo (non il token completo)
+ "plan": "professional",
+ "duration_months": 12,
+ "max_uses": 1,
+ "max_users_per_org": 10,
+ "price_eur": 990.00,
+ "expires_at": "2026-04-07 14:00:00", ← entro cui attivare
+ "channel": "ecommerce",
+ "label": "NIS2 Pro — Ordine MKT-001",
+ "issued_to": "cliente@co.it",
+ "invite_url": "https://nis2.agile.software/onboarding.html?invite=inv_a1b2...",
+ "provision_hint": "POST /api/services/provision con invite_token: ..."
+ }
+ ],
+ "count": 1,
+ "warning": "Salva i token subito — non saranno più visibili in chiaro."
+ }
+}
+
+ | Campo | Uso |
|---|---|
token | Il token completo inv_xxx — consegnarlo al cliente / lg231. Non viene mai restituito di nuovo. |
invite_url | URL diretto per onboarding browser — il cliente apre questo link e si registra |
expires_at | Entro quando il cliente deve attivare. Dopo questa data il token non funziona. |
id | ID numerico per revoca / dettaglio successivo |
+ Il pannello licenseExt.html ha un bottone
+ Esporta CSV che scarica tutte le licenze generate nella sessione.
+ In alternativa, esporta programmaticamente da GET /api/invites/list e converti.
+
# Parametri query (tutti opzionali): +?status=pending // pending | used | expired | revoked +&channel=ecommerce // ecommerce | lg231 | direct | reseller | manual +&limit=50 // default 50, max 100 +&offset=0 // paginazione + +# Esempio — tutte le licenze ecommerce attive: +GET /api/invites/list?channel=ecommerce&status=pending +X-API-Key: nis2_LA_TUA_CHIAVE+ +
+ Risposta: { invites: [...], total: N, limit: 50, offset: 0 }
+ Ogni licenza include tutti i campi tranne il token in chiaro (non recuperabile dopo creazione).
+
Dettaglio singola licenza. Se è stata usata, include used_by_org con nome azienda e settore.
+ Revoca la licenza (status → revoked). Il record rimane in DB per audit.
+
+ Genera un nuovo token invalidando il precedente. Stessa configurazione (piano, durata, scadenza). Usare se il token è stato inviato per errore o compromesso prima dell'uso. +
+ +| Stato licenza | Revocabile | Rigenerabile | Note |
|---|---|---|---|
pending | ✓ Sì | ✓ Sì | Normale |
used | ✗ No | ✗ No | Già attivata — azienda rimane attiva |
expired | ✓ Sì | ✓ Sì | Scaduta ma non usata |
revoked | — | ✓ Sì | Già revocata |
license_expires_at.
+ La revoca non disattiva retroattivamente nessun cliente.
+ + È il pannello admin lato marketing — non il cliente. +
+ +| Pagina | Audience | Funzione |
|---|---|---|
| licenseExt.html | +Marketing / Admin NIS2 | +Genera licenze, vede stats (aziende provisionate, utenti coinvolti), revoca, esporta CSV | +
onboarding.html?invite=inv_xxx |
+ Cliente finale | +Usa il token ricevuto per attivare la propria azienda in NIS2 (wizard 5 step) | +
| integrazioniext.html | +Partner tecnici (lg231, e-commerce) | +Documentazione flow completo, provisioning automatico B2B, webhook | +
super_admin.
+ Non è pubblica — accedervi comunicando l'URL ai soli membri del team autorizzati.
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + BASE URL https://nis2.agile.software/api + AUTH X-API-Key: nis2_xxxx (scope: admin:licenses) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +POST /invites/create Crea licenza/e +GET /invites/list Lista licenze (filtri: status, channel) +GET /invites/{id} Dettaglio singola +DELETE /invites/{id} Revoca +POST /invites/{id}/regenerate Nuovo token (stesso config) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + PUBBLICO (no auth): +GET /invites/validate?token=inv_ Anteprima invito (piano, scadenza) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + COSA CONTROLLA NIS2 (non il mktg): + ✓ N. aziende attivabili → max_uses + ✓ Tempo validità invito → invite_expires_days → expires_at + ✓ Durata licenza → duration_months → license_expires_at + ✓ N. utenti per azienda → max_users_per_org → license_max_users +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + PROVA IN 30 SECONDI: +curl https://nis2.agile.software/api/services/status+