Il contesto

Un cliente rivenditore di terminali POS aveva bisogno di un sito vetrina con e-commerce integrato. I requisiti erano chiari: nessun payment gateway da integrare (checkout con link di pagamento già pronti o estremi per bonifico), un catalogo di circa sette prodotti con varianti tariffarie selezionabili, e la possibilità che i propri venditori inserissero ordini direttamente dal sito — con il nome del commerciale salvato automaticamente sull'ordine, letto dall'identità già autenticata nel loro pannello gestionale interno.

La scelta di non usare piattaforme e-commerce preconfezionate era voluta: serviva qualcosa di completamente su misura, integrabile con l'ecosistema Firebase già in uso per il gestionale, e controllabile nei minimi dettagli. Stack risultante: HTML/CSS/JS vanilla, Firebase Realtime Database per catalogo e ordini, Firebase Storage per immagini e PDF, Netlify per l'hosting e le funzioni serverless.

Architettura: due Firebase separati

La prima decisione importante è stata tenere il database dell'e-commerce completamente separato da quello del pannello gestionale interno. Con circa quaranta ordini al giorno (circa quindicimila l'anno), unirli nello stesso progetto Firebase avrebbe creato rischio di contaminazione tra dati commerciali e dati amministrativi (stipendi, budget, anagrafiche).

La struttura del Realtime Database dell'e-commerce:

Firebase RTDB — struttura
{
  "products": {          // lettura pubblica, scrittura solo admin auth
    "{id}": {
      "name": "[Prodotto A]",
      "price": 0,
      "image": "https://...",
      "activeTariffs": ["tariff_id_1", "tariff_id_2"]
    }
  },
  "tariffs": {           // archivio globale tariffe, lettura pubblica
    "{id}": {
      "name": "[Piano A]", "code": "[CODICE-TARIFFA]",
      "canone": "€0/mese", "commissioni": "1,00%",
      "pdfUrl": "https://firebasestorage..."
    }
  },
  "orders": {            // lettura/scrittura solo utenti autenticati
    "{pushKey}": {
      "number": 1, "vendor": "[Venditore]",
      "customer": { "name": "...", "email": "..." },
      "items": [{ "product": "[Prodotto A]", "tariff": "[Piano A]" }]
    }
  },
  "counters": {          // non leggibile dal client, solo transazioni
    "lastOrderNumber": 42
  }
}

Varianti tariffarie: archivio globale + assegnazione per prodotto

Il requisito più interessante dal punto di vista del modello dati era la gestione delle tariffe. Ogni terminale POS può essere abbinato a più piani tariffari ([Piano A], [Piano B], [Piano C], ecc.), ma le stesse tariffe si ripetono su prodotti diversi. Incorporare le tariffe dentro ogni prodotto avrebbe significato duplicare i dati e aggiornare più nodi ogni volta che una tariffa cambia.

La soluzione è stata un archivio globale in /tariffs con tutte le tariffe definite una volta sola, e per ogni prodotto un array activeTariffs con gli ID delle tariffe applicabili. L'admin può spuntare o togliere tariffe da un prodotto con una checkbox, senza toccare i dati della tariffa stessa. I prodotti senza tariffe (es. una stampante) usano invece il flag noTariffs: true.

Nel frontend, quando l'utente seleziona una tariffa dalla scheda prodotto, un riquadro si aggiorna in tempo reale con tutti i dettagli: commissioni, canone mensile, conto e carta abbinati, condizioni economiche, servizi inclusi e — in evidenza con font monospace — il codice tariffa [Provider] da comunicare al cliente.

Numerazione ordini: transazione atomica Firebase

La numerazione progressiva degli ordini sembra banale, ma con più venditori che inseriscono ordini contemporaneamente diventa un problema classico di race condition. Se due venditori premono "Conferma" nello stesso istante, entrambi leggono lastOrderNumber = 41 e scrivono 42: un ordine sparisce.

Firebase Realtime Database risolve questo con le transazioni atomiche: la funzione runTransaction() tenta l'aggiornamento, e se il valore è cambiato nel frattempo sul server, ritenta automaticamente finché non riesce con il valore corretto.

JavaScript — transazione atomica ordine
// Incremento atomico del contatore — zero duplicati anche con più venditori
async function submitOrder(orderData) {
  const counterRef = ref(db, 'counters/lastOrderNumber');

  const { snapshot } = await runTransaction(counterRef, currentValue => {
    // currentValue è null al primo ordine
    return (currentValue || 0) + 1;
  });

  const orderNumber = snapshot.val(); // garantito unico

  const ordersRef = ref(db, 'orders');
  await push(ordersRef, {
    number:   orderNumber,
    vendor:   orderData.vendorName || 'Anonimo',
    customer: orderData.customer,
    items:    orderData.items,
    method:   orderData.paymentMethod,
    ts:       Date.now()
  });
}
💡

Il nodo /counters nelle regole Firebase ha ".read": false — non è leggibile dal client. Solo la transazione scrive su quel nodo, il numero arriva nella risposta della transazione stessa.

Admin panel: PBKDF2 senza password nel codice

Il pannello admin permette di gestire prodotti (nome, prezzo, immagini, tariffe assegnate) e l'archivio globale delle tariffe (codice [Provider], canone, condizioni, PDF dei T&C). Deve essere accessibile solo a chi conosce la password, ma senza un backend dedicato.

Mettere la password in chiaro nel codice sorgente è ovviamente fuori questione — chiunque veda il file HTML la legge. Ma con un hash non è abbastanza sicuro neanche usare MD5 o SHA-256 semplici: sono veloci da brute-force. La soluzione è PBKDF2 (Password-Based Key Derivation Function 2), che applica la funzione hash migliaia di volte rendendola computazionalmente costosa da attaccare.

La Web Crypto API del browser espone PBKDF2 nativamente. Al primo setup si genera l'hash fuori dal sito (o con un piccolo script Node) e si mette nel codice solo hash e salt — mai la password originale.

JavaScript — verifica password con Web Crypto PBKDF2
// Nel codice: solo salt e hash. La password non c'è mai.
const STORED_SALT = "a7f3..."; // generato casualmente al setup
const STORED_HASH = "e9b2..."; // PBKDF2 della password reale

async function verifyPassword(input) {
  const enc = new TextEncoder();
  const keyMaterial = await crypto.subtle.importKey(
    'raw', enc.encode(input), 'PBKDF2', false, ['deriveBits']
  );
  const derived = await crypto.subtle.deriveBits({
    name: 'PBKDF2',
    salt: hexToBuffer(STORED_SALT),
    iterations: 310000,          // costoso per il brute-force
    hash: 'SHA-256'
  }, keyMaterial, 256);

  return bufferToHex(derived) === STORED_HASH;
}

Dopo la verifica positiva, l'admin fa un sign-in anonimo Firebase in background: questo permette alle Security Rules del database di riconoscere l'utente come autenticato (auth != null) e di autorizzare le scritture su prodotti e tariffe. Il catalogo rimane leggibile pubblicamente, gli ordini sono protetti.

La trappola del valore zero in JavaScript

Durante lo sviluppo ho incontrato un bug sottile che vale la pena documentare: il prezzo di un terminale era zero (incluso nel canone), ma il sito continuava a mostrare "prezzo da definire" anche dopo averlo salvato correttamente.

Il problema era la falsy evaluation di JavaScript. In due punti del codice usavo pattern del tipo:

JavaScript — il bug
// ❌ SBAGLIATO: 0 è falsy, viene trattato come assente
const price = p.price || null;      // 0 → null → Firebase cancella il campo
const display = p.price ? ... : 'prezzo da definire'; // 0 → ramo else

// Nel template HTML dell'admin:
`value="${p.price || ''}"`  // 0 || '' → '', campo appare vuoto al reload

// ✅ CORRETTO: != null è true per qualsiasi numero, incluso lo zero
const price = p.price != null ? p.price : null;
const display = p.price != null ? ... : 'prezzo da definire';
`value="${p.price != null ? p.price : ''}"`

Il bug si manifestava in tre posti diversi con effetti diversi: nel salvataggio (il campo veniva cancellato da Firebase perché riceveva null), nel template admin (il campo appariva vuoto al reload e al salvataggio successivo scriveva di nuovo null), e nella visualizzazione frontend (mostrava il fallback invece del prezzo). La correzione è sempre la stessa: usare != null invece di || o dell'operatore ternario senza guard.

Content Security Policy e Firebase: sottodomini dinamici

Aggiungere una Content Security Policy è una buona pratica di sicurezza, ma Firebase Realtime Database usa il long-polling come canale di fallback rispetto ai WebSocket, e i sottodomini che genera sono dinamici e imprevedibili (es. s-gke-euw1-nssi1-1.europe-west1.firebasedatabase.app). Una CSP nel tag <meta> HTML non supporta i wildcard per i sottodomini, quindi Firebase veniva bloccato silenziosamente.

La soluzione è spostare la CSP dagli header HTML agli header HTTP di Netlify nel file netlify.toml, dove i wildcard funzionano correttamente:

netlify.toml — CSP con wildcard Firebase
[[headers]]
  for = "/*"
  [headers.values]
    Content-Security-Policy = """
      default-src 'self';
      script-src 'self' 'unsafe-inline'
        https://www.gstatic.com
        https://cdnjs.cloudflare.com;
      connect-src 'self'
        https://*.firebasedatabase.app
        wss://*.firebasedatabase.app
        https://*.googleapis.com
        https://firebasestorage.googleapis.com;
      img-src 'self' data: blob:
        https://firebasestorage.googleapis.com;
    """
    X-Frame-Options         = "DENY"
    X-Content-Type-Options  = "nosniff"
    Strict-Transport-Security = "max-age=31536000; includeSubDomains"
⚠️

I tag <meta http-equiv="Content-Security-Policy"> non supportano i wildcard di sottodominio in script-src. Per Firebase (e in generale per qualsiasi servizio che usa sottodomini dinamici) la CSP va sempre configurata negli header HTTP del server — su Netlify tramite netlify.toml.

UI: drawer laterale e slideshow immagini

Il catalogo mostra card con immagine a piena copertura (object-fit: contain per i terminali — verticali e sottili — invece di cover che li avrebbe ritagliati), nome e prezzo. Al click si apre un pannello laterale con immagine grande, descrizione, bottoni tariffa e riepilogo dettagliato.

Ogni prodotto può avere fino a tre immagini (principale + due supplementari caricate su Firebase Storage). Se presenti, nel pannello laterale si attiva automaticamente uno slideshow con avanzamento automatico ogni tre secondi, frecce di navigazione manuale e indicatori pallino. Il timer si azzerava correttamente alla chiusura del pannello per evitare che continuasse a girare in background.

Sicurezza: regole Firebase e architettura

La protezione reale non sta nel nascondere bottoni in JavaScript, ma nelle Security Rules di Firebase. La struttura definitiva:

Firebase RTDB — Security Rules
{
  "rules": {
    "products": {
      ".read": true,
      ".write": "auth != null"   // solo admin autenticato
    },
    "tariffs": {
      ".read": true,
      ".write": "auth != null"
    },
    "orders": {
      ".read":  "auth != null",  // solo venditori autenticati
      ".write": "auth != null"
    },
    "counters": {
      ".read":  false,            // mai leggibile dal client
      ".write": "auth != null"
    }
  }
}

Il passo successivo — non ancora completato in questa sessione — è sostituire il generico auth != null sulla sezione ordini con un custom claim Firebase (role: 'venditore') assegnato tramite la Netlify Function che valida il token del pannello gestionale. In questo modo solo i venditori con token valido emesso dal panel avranno accesso agli ordini, non qualsiasi utente autenticato.

Il risultato

Un e-commerce completamente su misura, senza piattaforme preconfezionate, costruito su uno stack già familiare: Firebase per i dati, Netlify per il deployment, vanilla JS per tutto il resto. Il venditore apre il sito dal pannello gestionale con un token nell'URL, viene riconosciuto automaticamente, seleziona il terminale e la tariffa, compila i dati del cliente, e l'ordine finisce su Firebase con numero progressivo garantito univoco e il suo nome abbinato. Il cliente riceverà la mail con il PDF dei T&C della tariffa scelta — non appena la Netlify Function per le email sarà collegata.

La parte più istruttiva del progetto non è stata la complessità tecnica, ma quante decisioni architetturali dipendono da requisiti apparentemente semplici: separare i database, tener le tariffe globali, usare transazioni invece di semplici write, spostare la CSP dagli header HTML a quelli HTTP. Ogni scelta ha evitato un problema che si sarebbe manifestato in produzione.