--- name: pyrite-app-it description: Standard per costruire e consegnare app per Pyrite, il motore aziendale che ospita le app dei reparti su PocketBase 0.40.6 (versione italiana dello standard; il testo di riferimento è quello inglese). Usalo ogni volta che crei o modifichi un'app per Pyrite - struttura della cartella, app.json, utenti e ruoli, regole delle API, script JS, strumenti $orch (anche dalle pagine, con toolAccess), modifiche concorrenti, rete in uscita, lavori pianificati, lingue, prove, pacchetto zip per «Carica app» e note di consegna. --- # Standard per gli agenti · Pyrite Versione dello standard: 0.7.7 (alpha). Motore: Pyrite 0.7.7 con PocketBase 0.40.6. Questa è la traduzione italiana dello standard; il testo di riferimento è quello inglese (`AGENT-STANDARD.md`) e i due dicono la stessa cosa. Alcuni nomi tecnici usano il prefisso `orch`: `$orch` negli script, le raccolte e i cookie `orch_*` e le variabili `ORCH_*`; le API del motore stanno sotto `/api/platform/`. **Cosa cambia nella 0.7.5**, per chi conosce lo standard della 0.7.4: - all'app si arriva in più modi, e a lei non deve importare quale: il solito `http://SERVER:PORTA/`, oppure con un **nome suo** (per esempio `https://prenota.app.esempio.it/`), oppure da internet se un Proprietario la apre (sezione 1.1). Niente indirizzo o porta scritti a mano nelle pagine, negli script o nelle impostazioni; - ogni app è «Solo rete locale» o «Anche internet»: lo decide un Proprietario, un Admin lo chiede. Non si scrive in `app.json` (sezione 1.1); - il motore scrive l'**indirizzo vero di chi chiama** in `X-Forwarded-For` e `X-Pyrite-Ip`; le stesse intestazioni scritte dal client non arrivano mai (sezione 1.1); - gli eventi del motore, anche quelli che arrivano dalle app (gli strumenti, la scheda Dati), vanno nello **storico del motore** con chi li ha fatti; lo **storico delle modifiche** (`history`) ora ha il nome della persona anche per le modifiche fatte da una rotta scritta dall'app. Le app non vanno cambiate (sezione 1.1). **Cosa cambia nella 0.7.7**, per chi conosce lo standard della 0.7.5: la `port` di `app.json` è una richiesta (chi carica l'app può scegliere un'altra porta, e dopo l'installazione la porta è dell'IT: un aggiornamento non la cambia mai, e tornando alla versione di prima si rimette la porta che l'app aveva prima); un `app.json` con due chiavi uguali a meno delle maiuscole è rifiutato; un'app caricata da un Admin avvisa i Proprietari con un'email, e un Proprietario può rifiutarla con un motivo. Questo documento è scritto per te, agente di IA che costruisce un'app per un reparto. Leggilo tutto prima di scrivere codice. Le regole numerate sono obbligatorie: se la richiesta del reparto le contraddice, non aggirarle, ma scrivilo nelle note di consegna (sezione 15). Gli esempi vengono dall'app di esempio «Parts price list» (Listino ricambi, `parts-pricelist`), collaudata nel motore e scaricabile insieme a questo documento; gli altri esempi di codice sono collaudati nello stesso motore. Il codice degli esempi è in inglese, come deve esserlo quello delle app (sezione 10). ## 1. Il contesto Pyrite è un programma per Windows (`pyrite.exe`) che gira su un server dell'azienda, in rete locale: le persone usano le app con indirizzi come `http://SERVER:8105/`, oppure `https://SERVER:8105/` quando l'IT accende HTTPS (un certificato per il motore e per tutte le porte delle app), oppure con un nome per ogni app; l'IT può anche aprire alcune app a internet (sezione 1.1). Il motore: - ospita le app dei reparti, ognuna nella sua cartella e, se ha dati, con il suo PocketBase 0.40.6 separato (processo e database propri); - fa da porta d'ingresso: decide chi entra in ogni app (accesso unico dal portale aziendale), conta le richieste, mostra una pagina chiara se l'app è ferma; - fa i backup ogni notte, li ripristina, riavvia le app che cadono, raccoglie misure e log; - offre una cassetta di **strumenti** pronti per i lavori pesanti (import ed export di file Excel e CSV, documenti PDF, foto, numerazioni, storico delle modifiche, statistiche, lavori pianificati), usabili dagli script dell'app con `$orch` e, quando l'app lo permette in `toolAccess`, dalle sue pagine (sezione 11); - protegge i record dalle modifiche concorrenti e lascia raggiungere agli script solo gli indirizzi di rete che l'app dichiara; - parla inglese e italiano: la console del motore, il portale e i messaggi del motore sono nelle due lingue, prima l'inglese. Quindi **non devi** costruire: server, login, pagine di registrazione, gestione delle password, backup, HTTPS, avvio automatico, invio di log, librerie per Excel, PDF o immagini (negli script o nelle pagine: lo fanno gli strumenti del motore). Devi costruire solo l'app: dati, regole, script e pagine. L'IT che gestisce il motore non è un programmatore: tutto quello che consegni deve funzionare senza interventi manuali, con le spiegazioni in parole semplici. L'IT installa la tua app da un file zip con la pagina «Carica app» (Upload app) della console, la controlla e la avvia. Nella console del motore le persone dell'IT hanno un livello: un **Proprietario** può fare tutto; un **Admin** gestisce solo le app che un Proprietario gli assegna, e un'app caricata da un Admin aspetta l'approvazione di un Proprietario prima di potersi avviare. ### 1.1 Come si arriva all'app 1. **L'indirizzo.** Le persone arrivano all'app con `http://SERVER:PORTA/` (o `https://`, quando l'HTTPS è acceso). Se l'IT sceglie i nomi, ogni app ha anche un nome suo, sotto il dominio di Pyrite (per esempio `https://prenota.app.esempio.it/`), e il portale ha il suo. Chi apre un'app con il nome senza essere entrato passa dal portale, mette la password una volta e torna da solo all'app; il motore non usa nessun cookie valido per tutto il dominio. L'IT può anche chiudere le porte delle app alla rete: allora le persone arrivano alle app solo passando dal motore, con i nomi. Quindi nelle pagine scrivi percorsi relativi (`/api/…`, `/__platform/portal`) e mai un indirizzo o una porta fissi, né nelle pagine né negli script. 2. **Internet.** Nel motore ogni app è «Solo rete locale» o «Anche internet» (scheda **Esposizione** dell'app). Le app nuove partono «Solo rete locale». La scelta è di un Proprietario; l'Admin dell'app la può togliere da internet da solo e, per aprirla, la chiede a un Proprietario (la richiesta compare in «Da guardare» e lo storico tiene chi ha chiesto e chi ha deciso). Un'app vista da internet si apre sempre dopo l'accesso al portale. Non è un campo di `app.json`: se l'app è pensata per essere usata da fuori, scrivilo in `DELIVERY.md`, così l'IT sa che cosa decidere. 3. **L'indirizzo di chi chiama.** Il motore scrive l'indirizzo vero di chi chiama in `X-Forwarded-For` e in `X-Pyrite-Ip`, e il PocketBase dell'app crede solo a `X-Forwarded-For` (in una rotta, `e.realIP()`): dietro un tunnel o un proxy è l'indirizzo della persona, non quello del tunnel. Le intestazioni `X-Forwarded-For`, `X-Forwarded-Proto`, `X-Pyrite-Ip` e `X-Orch-Token` scritte dal client non arrivano mai all'app. `CF-Connecting-IP`, `X-Real-IP` e `True-Client-IP` le scrive anche il motore, con l'indirizzo vero, e le altre intestazioni che portano l'indirizzo di un client (`X-Client-IP`, `Fastly-Client-IP`, `Forwarded`…) si tolgono: quello che il client scrive lì non arriva mai all'app. 4. **I collegamenti nelle email.** Per i collegamenti nelle email che l'app manda (verifica dell'indirizzo, password dimenticata) il motore scrive l'indirizzo giusto nelle impostazioni dell'app a ogni avvio, e di nuovo senza riavviarla quando cambiano i nomi: non scriverlo tu, verrebbe sostituito. 5. **Lo storico del motore.** Gli eventi del motore (chi ha acceso un'app, chi ha dato un accesso, chi è entrato, un file caricato, un dato ripristinato…) stanno nello **storico del motore**, un file a parte che dalla console nessuno cancella o cambia. Un evento che arriva da un'app (uno strumento, la scheda Dati) dice chi l'ha fatto, se la persona esiste davvero e, in un'app con login, ha l'accesso. Lo **storico delle modifiche** dei record (`history`, sezione 11) ora porta il nome della persona anche per le modifiche fatte da una rotta scritta dall'app, quando la chiama una persona entrata; i lavori pianificati dell'app e le chiamate senza login restano «script dell'app». Nelle app non c'è niente da cambiare. ## 2. Com'è fatta un'app Un'app è una cartella. Il nome della cartella è l'identificativo dell'app (`id` in `app.json`). ```text parts-pricelist\ la cartella dell'app (= id) ├── app.json la scheda: tipo, porta, accesso, ruoli, strumenti ├── DELIVERY.md note per l'IT (sezione 15) └── release\ il codice: si sostituisce senza toccare i dati ├── pb_migrations\ schema del database e dati iniziali (JavaScript) ├── pb_hooks\ regole e rotte lato server (JavaScript, file *.pb.js) │ └── lib\ codice comune, caricato con require() ├── pb_public\ le pagine: HTML, CSS, JS, immagini │ └── lib\ l'SDK di PocketBase (pocketbase.umd.js), copia locale ├── templates\ modelli dei PDF (.json, .html) e le loro immagini, solo con lo strumento pdf ├── import\ ricette di import dell'app (`*.json`), solo con lo strumento import └── seed\ file di dati iniziali (CSV, Excel, JSON), se servono ``` Il motore aggiunge da sé, accanto a `release\`, le cartelle `state\` (i dati: `state\pb_data`) e `logs\`. **Non** fanno parte dell'app che consegni. Gli script dell'app scrivono file solo dentro `state\`: `app.json`, `release\` e `logs\` per loro sono in sola lettura (sezione 8). I backup dell'app (e la loro copia sulla cartella di rete) contengono solo `state\pb_data`: un file che deve durare va in una raccolta (campo file), non altrove in `state\`, dove un ripristino non lo rimette. Un'app **statica** (solo pagine, senza database) ha solo `app.json`, le note di consegna e `release\public\` (attenzione: `public`, non `pb_public`). ## 3. La scheda app.json Esempio completo (l'app di esempio): ```json { "id": "parts-pricelist", "name": "Parts price list", "description": "Prices of the spare parts in the warehouse, updated from the supplier's Excel or CSV file.", "kind": "pocketbase", "access": "login", "port": 8105, "version": "0.1.0", "owner": "Spare parts warehouse", "auth": { "collection": "users", "roleField": "role", "roles": ["manager", "storekeeper"], "defaultRole": "storekeeper" }, "tools": ["import"] } ``` | campo | valori | significato | |---|---|---| | `id` | da 3 a 32 caratteri: minuscole, numeri, trattini (non all'inizio né alla fine) | identificativo stabile; uguale al nome della cartella. Riservati: `api`, `apps`, `console`, `admin`, `portale`, `accedi`, `archivio`, `control`, `platform`, `www`, e i nomi dei dispositivi di Windows (`con`, `prn`, `aux`, `nul`, da `com0` a `com9`, da `lpt0` a `lpt9`) | | `name` | testo, obbligatorio | il nome che le persone vedono nel portale | | `description` | testo | una riga che spiega a cosa serve | | `kind` | `static`, `pocketbase`, `pocketbase-cron`; obbligatorio | il tipo (sezione 4) | | `access` | `login` oppure `public` | `login`: entra solo chi ha l'accesso dato dall'IT; `public`: chiunque nella rete aziendale. Maiuscole e minuscole non contano. Se manca vale `login`; un altro valore non è rifiutato: il motore avvisa e tratta l'app come `login` | | `port` | da 1024 a 64000, obbligatorio | la porta pubblica dell'app. **La decide l'IT**: usa quella indicata nella richiesta; se non c'è, metti `8199` e scrivilo nelle note di consegna. Il motore usa anche la porta + 1000. **La porta è una richiesta**: chi carica l'app può sceglierne un'altra se questa è occupata o chiusa dal firewall di Windows, e il motore scrive la porta scelta nell'`app.json` installato. **Dopo l'installazione la porta è dell'IT**: un aggiornamento dell'app non la cambia (l'app resta sulla porta che ha, anche se il nuovo `app.json` ne chiede un'altra; la porta si cambia dalla scheda Configurazione). Quindi né l'app né i suoi documenti (note di consegna, README, collegamenti, script) devono contare su un indirizzo con la porta fissa: prendi la porta dalla richiesta, non scriverla in altri file, e fai rispondere l'app sulla porta che le dà il motore. | | `version` | per esempio `0.1.0` | la versione del codice: aumentala a ogni consegna | | `owner` | testo | il reparto o la persona di riferimento | | `color` | colore esadecimale, per esempio `#126a61` | facoltativo | | `memoryLimitMB` | numero intero | facoltativo; limite indicativo di memoria. Non metterlo se non te lo chiedono | | `auth` | oggetto | app PocketBase: utenti e ruoli (sezione 5). In un'app pubblica dichiara solo i ruoli che usa `toolAccess` | | `auth.collection` | nome di raccolta, di solito `users` | la raccolta degli utenti dell'app; se manca vale `users` | | `auth.roleField` | nome di campo | il campo dell'utente che contiene il ruolo. Obbligatorio se c'è `auth.roles`: non ha un valore predefinito, e senza di lui il motore non legge né scrive i ruoli (e avvisa l'IT). Chiamalo `role`, come fa il motore per le app che crea | | `auth.roles` | elenco | i ruoli, **dal più al meno potente** | | `auth.defaultRole` | uno dei ruoli | il livello base: il ruolo di chi entra per la prima volta. Se manca, il meno potente | | `auth.extra` | oggetto | campi fissi che il motore scrive sull'utente a ogni accesso, nuovo o già esistente (raro) | | `tools` | elenco, per esempio `["import"]` | gli strumenti del motore usati dagli script e dalle pagine (sezione 11). Solo quelli usati davvero. | | `toolAccess` | oggetto, per esempio `{ "export": { "pages": true, "minRole": "editor", "collections": ["orders"] } }` | quali strumenti possono usare le **pagine**, e chi (sezione 11, regola 8). Chiuso per difetto: senza una voce le pagine non usano nessuno strumento. Voci: `pages`, `minRole`, `destructiveRole`, `collections`, `maxRows`, `guests` (solo app pubbliche), `recipes` (import), `snapshots` (stats), `templates` e `maxPages` (pdf), `keyFields` (images: i campi che possono ricevere o confrontare il nome del file, per esempio `["products.code"]`; senza, nessuno). L'IT le vede in «Carica app» e nella scheda «Strumenti per le pagine» | | `toolJobs` | `{ "heavy": 1 }`, da 1 a 4 | quanti lavori lunghi partiti dalle pagine girano insieme; predefinito 1 | | `concurrency` | `"strict"` (predefinito), `"warn"`, `"off"` (maiuscole e minuscole non contano; un altro valore vale strict) | la protezione dalle modifiche concorrenti (sezione 9, regola 10). In un'app nuova non metterlo: vale strict | | `numbering` | oggetto | i contatori dello strumento `numbering` (sezione 11) | | `history` | oggetto, per esempio `{ "collections": ["orders"] }` | le raccolte che tengono lo storico delle modifiche (strumento `history`, sezione 11) | | `images` | oggetto, per esempio `{ "products.photo": { "maxSide": 1600 } }` | le regole dello strumento `images` per i campi delle foto (sezione 11) | | `network` | oggetto, per esempio `{ "allow": ["api.openai.com"] }` | gli indirizzi che gli script possono chiamare (sezione 8.1). `allow`: in https; `allowHttp`: anche in http semplice, solo per sistemi interni senza https. Senza `network` gli script non possono chiamare nessun indirizzo | | `import` | oggetto, per esempio `{ "consoleCollections": ["suppliers", "orders"] }` | `consoleCollections` elenca le sole raccolte in cui la scheda «Importa» della console può importare (e annullare); `[]` = la console non importa; senza, tutte. Consigliato per un'app che tiene i dati in ordine con le sue rotte o i suoi hook di richiesta (spostamenti, storici, stati). Sezione 11, «Import» | `name` e `description` sono un testo solo: scrivili nella lingua principale delle persone che usano l'app (il portale li mostra così come sono). I nomi dei ruoli: minuscole, numeri e trattino basso, da 2 a 30 caratteri (per esempio `manager`, `workshop_head`). Il motore scarta la scheda (e quindi l'app) se non è JSON valido, se `kind` non è uno dei tre valori, se manca `name`, se `port` è fuori intervallo o già usata (anche la porta + 1000), se `defaultRole` non è tra i ruoli, se l'`id` non è valido, è riservato o è già registrato (se manca `id` vale il nome della cartella; in uno zip l'app deve allora stare in una cartella sola). «Carica app» fa gli stessi controlli prima di installare (sezione 15). ## 4. Scegliere il tipo di app | tipo | quando | cosa c'è | |---|---|---| | `static` | solo pagine: un calcolatore, una guida, una lista che si legge da un file fornito con l'app | `release\public\`. Nessun database, nessuno script lato server. Con `access: "login"` la porta chiede l'accesso, ma la pagina non sa chi è la persona | | `pocketbase` | quasi sempre: dati da salvare, utenti con ruoli, regole | PocketBase 0.40.6 tutto per l'app: database SQLite, API REST e realtime, migrazioni, hook JS, pagine in `pb_public` | | `pocketbase-cron` | come `pocketbase`, con attività programmate (`cronAdd`) | come sopra; il tipo dice all'IT che l'app lavora anche da sola | Se l'app deve ricordare qualcosa (anche solo preferenze condivise o un elenco modificabile), è `pocketbase`. Non salvare dati condivisi nel `localStorage` del browser. ## 5. Utenti, accesso e ruoli Il principio: **chi entra lo decide l'IT, al centro, e le persone entrano solo dal portale; il ruolo dentro l'app lo gestisce il motore insieme all'app.** 1. Nelle app con `access: "login"` la persona entra dal portale aziendale con le credenziali della rubrica centrale. Il motore crea nella raccolta `users` dell'app un utente con l'email e il nome della persona e **lo lega alla persona per ID**: quando l'IT cambia l'email di una persona, o la persona cambia il nome visualizzato nel profilo, il motore allinea l'utente dell'app, che resta lo stesso record con lo stesso `id`. Il motore gli rilascia un accesso e lo mette nel browser (chiave `pocketbase_auth` del `localStorage`, quella che l'SDK di PocketBase usa da solo). Quando la pagina si apre, l'utente è già autenticato: `pb.authStore.record`. 2. L'app **non deve** avere: pagine di login, registrazione, «password dimenticata», cambio password, pulsante «Esci». Le password sono della rubrica: il motore applica le regole delle password (maiuscola, minuscola, numero, carattere speciale, lunghezza minima decisa dall'IT) e il cambio obbligatorio al primo accesso. Per tornare al portale usa il collegamento `/__platform/portal`; si esce dal portale. 3. L'identità è bloccata dal motore: dalle API dell'app non si possono creare utenti né cambiare email, nome o password. Non provarci e non mostrare campi per farlo. Per dire di chi è un record usa l'`id` dell'utente (un campo `relation` verso `users`), mai l'email: l'email può cambiare, l'`id` resta. La persona ha una pagina del profilo nel motore (`/profile/`, dal portale): nome visualizzato, lingua, tema, password e le sessioni aperte, con «Esci da tutte le sessioni», che chiude anche le sessioni nelle app (la chiamata successiva della pagina risponde `401`: sezione 9, regola 4). L'app non deve offrire niente di tutto questo. 4. Il **ruolo** è un campo `select` della raccolta utenti, chiamato `role` (dichiarato in `auth.roleField`), con gli stessi valori di `auth.roles`; quando l'app gira nel motore, il campo accetta anche i valori di `auth.roles`. Alla prima entrata (e ogni volta che il ruolo dell'utente è vuoto) il motore scrive il livello base (`auth.defaultRole`); altrimenti non lo tocca. Il ruolo lo cambiano: - l'IT, dalla console del motore (pagina dell'app, scheda «Accessi e ruoli»: un Proprietario o un Admin che gestisce l'app); - se serve, chi ha il ruolo più alto, da una pagina dell'app, se la regola `updateRule` di `users` glielo permette; quando `toolAccess` usa `minRole` o `destructiveRole`, nessuno può dare un ruolo più potente del proprio. 5. Nelle app con login nessuno può cambiare il **proprio** ruolo: lo blocca il motore (non nella prova locale della sezione 12.1, dove il motore manca). Nelle app pubbliche questo blocco non c'è: solo quando `toolAccess` usa `minRole` o `destructiveRole` il motore impedisce di dare un ruolo più potente del proprio (abbassarlo si può); altrimenti decide la `updateRule` di `users`. 6. Il campo del ruolo si crea con una migrazione (sezione 7.3). Le regole lo leggono come `@request.auth.role`. 7. Nelle app con `access: "public"` chiunque nella rete aziendale apre le pagine senza entrare: le richieste sono anonime. **Nessuna app ha l'iscrizione libera, nemmeno le pubbliche**: chi ha un utente in un'app lo decide l'IT: nelle app con login con le persone e i gruppi del motore, nelle app pubbliche creandoli nella scheda Dati dell'app (sezione 13). Se un'app pubblica ha bisogno di utenti (per esempio le poche persone che possono scrivere), li crea l'IT o li creano gli script dell'app (mai nelle migrazioni: regola 8), mai una pagina di registrazione: il motore rifiuta ogni richiesta API che crea un record in una qualunque raccolta di utenti (non solo `users`; anche con OAuth2 e dentro `/api/batch`): con `403` nelle app pubbliche, con `400` nella raccolta `users` di un'app con login; solo un superuser dell'app, cioè il pannello che apre il motore, può creare utenti e avvisa l'IT se una raccolta la chiede con la sua `createRule`. Tieni la `createRule` di `users` a `null` (sezione 6, regola 5). Non mettere dati riservati in un'app pubblica e non permettere scritture anonime se non sono davvero lo scopo dell'app (per esempio un modulo di segnalazione); in quel caso concordalo con l'IT e scrivilo nelle note. 8. Non creare utenti, account di prova o amministratori nelle migrazioni, e non scrivere password da nessuna parte. ## 6. Regole di accesso alle API (obbligatorie) PocketBase espone ogni raccolta come API REST. Chi può leggere e scrivere lo decidono **solo** le regole della raccolta (`listRule`, `viewRule`, `createRule`, `updateRule`, `deleteRule`), controllate dal server. Le pagine non proteggono niente: chiunque abbia accesso all'app può chiamare le API direttamente. 1. **Ogni raccolta ha le cinque regole scritte esplicitamente nella migrazione che la crea.** Mai lasciarle al valore di partenza. 2. `null` vuol dire «nessuno» (solo il motore e l'IT). `""` (testo vuoto) vuol dire «**chiunque**, anche senza login». **Mai `""` su `createRule`, `updateRule`, `deleteRule`.** `""` su `listRule`/`viewRule` solo in un'app pubblica e solo per dati davvero pubblici. 3. **Ogni regola di un'app con login inizia con `@request.auth.id != ''`.** Senza login `@request.auth.id` vale `''`: una regola come `requester = @request.auth.id` diventa vera per tutti i record con `requester` vuoto, e quei record diventano leggibili e modificabili da chiunque. La forma giusta è: ```js listRule: "@request.auth.id != '' && (@request.auth.role = 'buyer' || requester = @request.auth.id)", ``` 4. I campi che decide il server (autore, stato di approvazione, numeri progressivi, date di convalida) non si accettano dal client: o li imposta un hook (sezione 7.1), o la regola li vieta con `@request.body.campo:isset = false`. 5. La raccolta `users` dell'app ha regole strette, in ogni app, anche pubblica: `createRule: null` (nessuna iscrizione), `deleteRule: null`, lettura del proprio utente (e, se serve, dei colleghi per chi ha il ruolo più alto), `updateRule` solo per chi può cambiare i ruoli degli altri oppure `null`. 6. Le rotte che scrivi tu (`routerAdd`) **non** passano dalle regole delle raccolte: proteggile con `$apis.requireAuth("users")` e controlla il ruolo nel codice. Allo stesso modo `e.app.save(...)` negli script scrive senza regole: lo script è il server. Anche gli strumenti `$orch` chiamati dagli script scrivono come il server; chiamati dalle pagine (`/api/tools/`) applicano le regole dell'app per la persona e i permessi di `toolAccess` (sezione 11, regole 6 e 8). 7. Prova le regole davvero: senza login, con ogni ruolo, chiamando le API direttamente (sezione 12). Regole tipiche (ruoli `manager` e `storekeeper`, campo `role`): | cosa | regola | |---|---| | leggono tutti quelli che entrano | `@request.auth.id != ''` | | scrive solo il responsabile (`manager`) | `@request.auth.id != '' && @request.auth.role = 'manager'` | | ognuno vede i propri record | `@request.auth.id != '' && author = @request.auth.id` | | il proprio record finché è in bozza, senza toccare lo stato | `@request.auth.id != '' && author = @request.auth.id && status = 'draft' && @request.body.status:isset = false` | | nessuno da API (solo script e IT) | `null` | La migrazione dell'app di esempio che crea il campo del ruolo, le raccolte e le loro regole: ```js /// // Parts price list: the users' role, the price list and the files uploaded by the manager. // In migrations the constants written out here can be used (in hook handlers they can't). const AUTH = "@request.auth.id != ''"; const MANAGER = "@request.auth.id != '' && @request.auth.role = 'manager'"; migrate((app) => { // users: the engine creates them (same email as the company directory); here only the role field and strict rules const users = app.findCollectionByNameOrId("users"); if (!users.fields.getByName("role")) { users.fields.add(new SelectField({ name: "role", maxSelect: 1, values: ["manager", "storekeeper"] })); } users.listRule = AUTH + " && (id = @request.auth.id || @request.auth.role = 'manager')"; users.viewRule = AUTH + " && (id = @request.auth.id || @request.auth.role = 'manager')"; users.createRule = null; // no sign-up: IT decides who gets in users.updateRule = MANAGER; // the manager changes the colleagues' role (not their own: the engine blocks it) users.deleteRule = null; app.save(users); const parts = new Collection({ type: "base", name: "parts", listRule: AUTH, viewRule: AUTH, createRule: MANAGER, updateRule: MANAGER, deleteRule: MANAGER, fields: [ { name: "code", type: "text", required: true, max: 40, presentable: true }, { name: "description", type: "text", required: true, max: 300 }, { name: "price", type: "number", min: 0 }, { name: "supplier", type: "text", max: 120 }, { name: "updated_by", type: "text", max: 200 }, { name: "created", type: "autodate", onCreate: true, onUpdate: false }, { name: "updated", type: "autodate", onCreate: true, onUpdate: true }, ], indexes: ["CREATE UNIQUE INDEX idx_parts_code ON parts (code)"], }); app.save(parts); // the supplier's files: the manager uploads them, then the route /api/pricelist/import/{id} imports them const uploads = new Collection({ type: "base", name: "uploads", listRule: MANAGER, viewRule: MANAGER, createRule: MANAGER + " && @request.body.import_id:isset = false && @request.body.result:isset = false && @request.body.undone:isset = false", updateRule: null, deleteRule: null, fields: [ { name: "file", type: "file", required: true, maxSelect: 1, maxSize: 20971520 }, { name: "import_id", type: "text", max: 40 }, { name: "result", type: "json", maxSize: 2000000 }, { name: "undone", type: "bool" }, { name: "created", type: "autodate", onCreate: true, onUpdate: false }, { name: "updated", type: "autodate", onCreate: true, onUpdate: true }, ], }); app.save(uploads); }, (app) => { for (const name of ["uploads", "parts"]) { try { app.delete(app.findCollectionByNameOrId(name)); } catch (_) {} } const users = app.findCollectionByNameOrId("users"); users.fields.removeByName("role"); app.save(users); }); ``` ## 7. Script JS lato server (PocketBase 0.40.6) Gli script girano dentro il PocketBase dell'app, con il motore JavaScript goja: JavaScript moderno, ma senza Node.js, senza `npm`, senza `fetch` e senza `async`/`await` (gli handler sono sincroni). Usa solo le API di PocketBase 0.40.6; non copiare esempi di versioni vecchie (prima della 0.23 le API JS erano diverse: niente `$app.dao()`, niente `onRecordBeforeCreateRequest`). ### 7.1 Hook e rotte (`release\pb_hooks\*.pb.js`) PocketBase carica solo i file che finiscono con `.pb.js`, in ordine alfabetico, all'avvio dell'app: dopo una modifica l'app va riavviata. 1. **Ogni handler gira in una macchina JS separata**: non vede variabili, costanti o funzioni scritte fuori dall'handler, nello stesso file. Il codice comune va in un modulo (per esempio `pb_hooks\lib\rules.js`, senza `.pb.js`) e si carica **dentro** l'handler con `require(__hooks + "/lib/rules.js")`. Dentro il modulo, le costanti e le funzioni del modulo stesso si vedono. 2. Negli hook dei record e delle richieste chiama sempre `e.next()` per proseguire; per bloccare, lancia un errore: `throw new BadRequestError("messaggio per la persona")` (anche `ForbiddenError`, `NotFoundError`, `UnauthorizedError`). 3. Usa `e.app` dentro gli handler (è l'app giusta anche dentro una transazione); `$app` va bene nelle rotte e nei `cronAdd`. Gli strumenti `$orch` usano da soli l'app giusta (sezione 11, regola 5). 4. `e.auth` è l'utente che fa la richiesta (oppure `null`): `e.auth.getString("role")`, `e.auth.email()`, `e.auth.id`. 5. Dati della richiesta: `e.requestInfo().body`, `e.request.pathValue("id")`, `e.request.url.query().get("x")`, `e.request.header.get("Accept-Language")`. Risposte: `e.json(200, oggetto)`, `e.string(200, testo)`, `e.noContent(204)`. 6. SQL solo con parametri legati, mai concatenando testi: `e.app.db().newQuery("... WHERE x = {:x}").bind({ x: valore })`. Per leggere le righe: `arrayOf(new DynamicModel({ text: "", whole: 0, decimal: -0, flag: false }))` (per i numeri con decimali la forma è `-0`, non `0`). 7. Cerca i record con i filtri e i parametri: `e.app.findRecordsByFilter("suppliers", "company_name ~ {:s}", "-created", 100, 0, { s: search })`, `e.app.findFirstRecordByFilter("suppliers", "account = {:a}", { a: account })`, `e.app.findRecordById("parts", id)`. Scorri i risultati con `for (const r of records)`. 8. Più scritture che devono riuscire insieme: `e.app.runInTransaction((txApp) => { ... txApp.save(r) ... })`, usando `txApp` dentro. 9. Date: si salvano in UTC (`new DateTime()` è adesso); il formato si sistema nella pagina. 10. `console.log` finisce nell'output del processo, che l'IT vede nella console del motore (scheda Stato → «Output del processo»). Non scriverci dati personali. 11. **Il prefisso `/api/tools/` è del motore** (gli strumenti dalle pagine): non registrarci mai rotte. «Carica app» rifiuta un pacchetto nuovo che registra una rotta scritta sotto `/api/tools/` (una rotta con un segnaposto che potrebbe coincidere, come `/api/{coll}/{id}/approve`, è solo un avviso, e all'avvio lascia le pagine dell'app senza gli strumenti); un'app già installata che le ha tiene le sue rotte, ma le sue pagine non possono usare gli strumenti del motore finché le rotte non si spostano. Metti le rotte dell'app sotto un nome dell'app, per esempio `/api/pricelist/…` come nell'esempio. 12. I salvataggi degli script non passano dalla protezione dalle modifiche concorrenti (sezione 9, regola 10): se una rotta cambia un record per conto di una persona dopo averglielo mostrato, confronta tu la versione oppure usa le pagine. Esempio (l'app di esempio): campi decisi dal server, rotta protetta che usa uno strumento del motore, annullamento e riepilogo con SQL. ```js /// // Parts price list: who changed a part, and the import of the supplier's file. // Every handler runs in a separate JS machine: it doesn't see constants or functions written out here. // The shared code is in lib/rules.js and is loaded with require() inside the handler. onRecordCreateRequest((e) => { e.record.set("updated_by", e.auth ? (e.auth.getString("name") || e.auth.email()) : ""); e.next(); }, "parts"); onRecordUpdateRequest((e) => { e.record.set("updated_by", e.auth ? (e.auth.getString("name") || e.auth.email()) : ""); e.next(); }, "parts"); // imports the file of an "uploads" record: first the trial run, then the real load routerAdd("POST", "/api/pricelist/import/{id}", (e) => { const rules = require(__hooks + "/lib/rules.js"); rules.managerOnly(e); const rec = e.app.findRecordById("uploads", e.request.pathValue("id")); if (rec.getString("import_id")) { throw new BadRequestError(rules.text(e, "already_loaded")); } const file = $filepath.join(e.app.dataDir(), "storage", rec.collection().id, rec.id, rec.getString("file")); const recipe = rules.pricelistRecipe(e, file); const trial = $orch.import.load(file, recipe, { dryRun: true, app: e.app }); if (trial.rejected > 0) { return e.json(400, { message: rules.text(e, "rows_to_fix", { n: trial.rejected }), result: trial }); } const result = $orch.import.load(file, recipe, { by: e.auth.email(), app: e.app }); rec.set("import_id", result.id); rec.set("result", result); e.app.save(rec); return e.json(200, result); }, $apis.requireAuth("users")); // undoes the import of a file: removes the parts it created and restores the ones it updated routerAdd("POST", "/api/pricelist/undo/{id}", (e) => { const rules = require(__hooks + "/lib/rules.js"); rules.managerOnly(e); const rec = e.app.findRecordById("uploads", e.request.pathValue("id")); if (!rec.getString("import_id")) { throw new BadRequestError(rules.text(e, "not_loaded")); } if (rec.getBool("undone")) { throw new BadRequestError(rules.text(e, "already_undone")); } const result = $orch.import.undo(rec.getString("import_id"), { by: e.auth.email(), app: e.app }); rec.set("undone", true); e.app.save(rec); return e.json(200, result); }, $apis.requireAuth("users")); // summary per supplier: an SQL query with the parameters always bound, never concatenated routerAdd("GET", "/api/pricelist/summary", (e) => { const min = parseFloat(e.request.url.query().get("min") || "0") || 0; // shape of a row: "" text, 0 whole number, -0 number with decimals const rows = arrayOf(new DynamicModel({ supplier: "", parts: 0, average_price: -0 })); e.app.db() .newQuery("SELECT supplier, COUNT(*) AS parts, AVG(price) AS average_price FROM parts WHERE price >= {:min} GROUP BY supplier ORDER BY supplier") .bind({ min: min }) .all(rows); return e.json(200, rows); }, $apis.requireAuth("users")); ``` Il modulo comune `pb_hooks\lib\rules.js`, con i testi nelle due lingue dell'app e la ricetta costruita dal file del fornitore: ```js // Shared rules of the price list: loaded inside the handlers with require(__hooks + "/lib/rules.js"). // The name doesn't end with .pb.js, so PocketBase doesn't load it on its own as a hook file. // Inside a module, the constants and functions written here are visible to the module's functions. // texts for people, in the two languages of the app (the page tells its language with Accept-Language) const TEXTS = { en: { manager_only: "Only the warehouse manager can do this.", already_loaded: "This file has already been loaded.", not_loaded: "This file hasn't been loaded.", already_undone: "This import has already been undone.", rows_to_fix: "{n} rows need fixing: nothing was loaded.", missing_columns: "The file doesn't have the columns {list}.", }, it: { manager_only: "Solo il responsabile del magazzino può farlo.", already_loaded: "Questo file è già stato caricato.", not_loaded: "Questo file non è stato caricato.", already_undone: "Questo caricamento è già stato annullato.", rows_to_fix: "Ci sono {n} righe da correggere: non è stato caricato niente.", missing_columns: "Nel file mancano le colonne {list}.", }, }; // the columns of the supplier's file: field of "parts" → accepted column titles (English or Italian) const COLUMNS = { code: ["Code", "Codice"], description: ["Description", "Descrizione"], price: ["Price", "Prezzo"], supplier: ["Supplier", "Fornitore"], }; const REQUIRED = ["code", "description", "price"]; // lang: the language of the request, "it" if the page asks for Italian, otherwise "en" function lang(e) { return /^it\b/i.test(e.request.header.get("Accept-Language")) ? "it" : "en"; } // text: a text for the person, in the language of the request, with its {placeholders} filled in function text(e, key, vars) { let t = TEXTS[lang(e)][key]; for (const name in vars || {}) { t = t.replace("{" + name + "}", String(vars[name])); } return t; } // managerOnly: only the warehouse manager (field "role" of the users, app.json → auth.roleField) function managerOnly(e) { if (!e.auth || e.auth.getString("role") !== "manager") { throw new ForbiddenError(text(e, "manager_only")); } } // pricelistRecipe: the import recipe for the supplier's file (Excel or CSV, one row per part). The file is // analyzed first: the CSV separator and the column titles (in English or in Italian) come from the file itself. function pricelistRecipe(e, file) { let a = $orch.import.analyze(file, { header: "titles" }); if (a.format === "csv" && a.suggestedSeparator && a.suggestedSeparator !== a.separator) { a = $orch.import.analyze(file, { header: "titles", separator: a.suggestedSeparator }); } const titles = a.columns.map((c) => c.name); const fields = {}; const missing = []; for (const field of Object.keys(COLUMNS)) { const title = titles.find((t) => COLUMNS[field].some((name) => name.toLowerCase() === t.trim().toLowerCase())); if (title) { fields[title] = field; } else if (REQUIRED.includes(field)) { missing.push(COLUMNS[field][lang(e) === "it" ? 1 : 0]); } } if (missing.length) { throw new BadRequestError(text(e, "missing_columns", { list: missing.join(", ") })); } const options = { format: a.format, header: "titles" }; if (a.format === "csv") { options.separator = a.separator; } return { name: "Supplier price list", collection: "parts", options: options, fields: fields, key: ["code"] }; } module.exports = { lang, text, managerOnly, pricelistRecipe }; ``` Attività programmate (`kind: "pocketbase-cron"`), in un file `.pb.js`: ```js cronAdd("overdue-reminders", "0 7 * * 1-5", () => { const late = $app.findRecordsByFilter("cases", "due != '' && due < {:today} && closed = false", "due", 500, 0, { today: new DateTime() }); for (const c of late) { c.set("overdue", true); $app.save(c); } }); ``` Il motore registra ogni esecuzione di ogni lavoro (inizio, durata, esito, errore), lascia all'IT pausa, ripresa e «Avvia ora» dalla console, e scrive un evento quando un lavoro comincia a fallire. Quindi: - dai a ogni lavoro un **nome stabile e leggibile** (il primo argomento di `cronAdd`, minuscole, cifre e trattini): è la chiave dello storico e della pausa; - la pianificazione è in **UTC**, come in PocketBase (`"0 7 * * 1-5"` sono le 07:00 UTC, le 08:00 o le 09:00 in Italia); - lascia uscire gli errori dalla funzione (o lancia il tuo `new Error("…")`): un `catch` vuoto fa sembrare a posto un lavoro fallito; - **un riavvio dell'app interrompe le esecuzioni in corso**: quello chiesto dall'IT e anche quello automatico dopo una modifica di porta interna, limite di memoria, `access`, ruoli/raccolta/campo di `auth` o modo della rete (le modifiche a `network`, `toolAccess`, `concurrency` si rileggono senza riavvio). L'esecuzione resta nello storico come «interrotta». Scrivi i lavori lunghi in modo che si possano rifare, a pezzi, mai tutto o niente. I dettagli sono nella sezione 11, «Lavori pianificati». **Rotte lente.** Il motore aspetta al massimo **4 minuti e 50 secondi** la risposta di una rotta dei tuoi script (il limite di PocketBase stesso è di 5 minuti). Se la rotta non ha risposto entro quel tempo, chi chiama riceve un `504` con un messaggio chiaro; se l'app chiude la connessione senza rispondere (anche di colpo, per esempio se il processo dell'app si ferma per un guasto), un `502`. Un'app ferma o in riavvio dà `503`. Il motore **non ripete mai** una richiesta già inviata: la rotta può aver finito il lavoro lo stesso, quindi controlla prima di riprovare (un secondo clic non deve creare un secondo record). Se chi chiama se ne va prima della risposta, il motore conta la richiesta ma non come riuscita né come errore (stato `499` nei suoi contatori). Un `504` o un `502` lascia un avviso negli eventi del motore. Quindi non scrivere rotte che durano minuti: per un lavoro lungo avvia un lavoro (sezione 11, `jobs`) e rispondi subito. ### 7.2 Migrazioni (`release\pb_migrations\`) 1. Lo schema del database cambia **solo** con le migrazioni: file `_.js`, applicati una volta sola in ordine di nome. Usa come numero un timestamp Unix (per esempio `1793600000_parts.js`) e fai crescere il numero a ogni nuovo file. 2. Una migrazione già consegnata **non si modifica, non si rinomina e non si toglie mai** (sezione 15, punto 8): per cambiare lo schema aggiungi un nuovo file. Scrivi sempre anche la funzione che torna indietro (secondo argomento di `migrate`). 3. Nelle migrazioni usa l'`app` che ricevi (`migrate((app) => { ... })`), non `$app`. Le costanti scritte fuori da `migrate` si possono usare. 4. Raccolte con `new Collection({ type: "base", name, listRule, viewRule, createRule, updateRule, deleteRule, fields: [...], indexes: [...] })` e `app.save(collection)`. Tipi di campo: `text`, `editor`, `number`, `bool`, `email`, `url`, `date`, `autodate`, `select`, `file`, `relation`, `json`, `geoPoint`. Aggiungi sempre `created` e `updated` di tipo `autodate` (l'ordinamento per `-created` ne ha bisogno). 5. Non toccare le raccolte di sistema (`_superusers`, `_mfas`, `_otps`, `_externalAuths`, `_authOrigins`) né quelle del motore (`orch_*`). Della raccolta `users` cambia solo le regole e aggiungi campi tuoi (il ruolo, il reparto…). 6. Dati iniziali: da file in `release\seed\` (CSV o Excel con lo strumento `import`, sezione 11; JSON con `$os.readFile` e `JSON.parse(toString(...))`). Mai dati personali reali senza accordo con l'IT, mai utenti né password. Esempio (l'app di esempio): dati iniziali da un CSV consegnato con l'app. ```js /// // Initial price list, delivered with the app in release/seed (engine tool "import"). migrate((app) => { $orch.import.load("release/seed/initial_parts.csv", { name: "Initial price list", collection: "parts", options: { format: "csv", separator: ";" }, fields: { "Code": "code", "Description": "description", "Price": "price", "Supplier": "supplier" }, key: ["code"], }, { app: app, by: "initial data" }); }, (app) => { app.db().newQuery("DELETE FROM parts").execute(); }); ``` Esempio: dati iniziali da un file JSON. ```js /// // Departments delivered with the app as JSON in release/seed (for JSON no engine tool is needed). migrate((app) => { const list = JSON.parse(toString($os.readFile("release/seed/departments.json"))); const departments = app.findCollectionByNameOrId("departments"); for (const d of list) { const rec = new Record(departments); rec.set("code", d.code); rec.set("name", d.name); app.save(rec); } }, (app) => { app.db().newQuery("DELETE FROM departments").execute(); }); ``` ### 7.3 Il campo del ruolo Se l'app ha ruoli, la prima migrazione aggiunge alla raccolta utenti il campo dichiarato in `auth.roleField` (`role`), con gli stessi valori di `auth.roles`, e mette le regole di `users` (vedi l'esempio nella sezione 6). ### 7.4 I dati delle persone nel pacchetto Un pacchetto porta con sé solo i dati che servono a far partire l'app. I dati delle persone (clienti, dipendenti, fornitori con nome e indirizzo) sono un'altra cosa e hanno regole loro. 1. **Nel pacchetto** vanno solo gli elenchi, le tabelle di servizio e pochi esempi inventati (nell'app di esempio: il listino iniziale e i reparti, sezione 7.2, punto 6). 2. **Un archivio vero con dati di persone non si chiude nello zip.** Lo carica l'IT dopo l'installazione, con lo strumento di import del motore (scheda «Importa» della console, o `$orch.import`), da un file che resta dove l'IT decide. Nel pacchetto metti al massimo la ricetta di import (`release\import\*.json`) e, in `DELIVERY.md`, la descrizione delle colonne attese. 3. **I dati iniziali si importano con lo strumento del motore** (`$orch.import.load`, come nell'esempio della sezione 7.2), mai con SQL diretto né con un salvataggio riga per riga fatto a mano. Così ogni import resta nello storico degli import del motore e passa dai controlli (prova a secco, chiavi, relazioni). Per un piccolo elenco di servizio in JSON l'esempio della sezione 7.2 va bene. 4. **`DELIVERY.md` ha una riga obbligatoria «Dati personali»**: quali dati di persone ci sono, da dove vengono e quali ruoli li vedono. Se non ce ne sono, scrivi «nessuno». 5. Nella lista di controllo finale (sezione 14) c'è la riga che lo verifica. ## 8. Cosa uno script non può fare (il recinto) Gli script di ogni app sono chiusi nella cartella dell'app. Il motore blocca, con un errore in inglese: - avviare programmi (`$os.cmd`, `$os.exec`) e chiudere il processo (`$os.exit`); - leggere file fuori dalla cartella dell'app (`$os.readFile`, `$filepath.walk`, `$filepath.glob`, `$filesystem.fileFromPath`, `$template.loadFiles`, `require` con percorsi che escono, anche dentro un modulo); - scrivere, rinominare o cancellare file fuori da `state\` (`$os.writeFile`, `$os.mkdir`, `$os.rename`, `$os.remove` e simili): `app.json`, `release\` e `logs\` gli script li possono solo leggere. Se l'app deve salvare un file suo (per esempio un file temporaneo o un export), lo mette in `state\`; i file caricati dalle persone vanno in un campo file di una raccolta; - usare il database per aprire o scrivere altri file: `ATTACH`, `DETACH`, `VACUUM INTO` fuori da `state\`, estensioni (`load_extension`); - usare nomi che Windows legge come un altro: una parte del percorso che finisce con un punto o uno spazio (`state.`), o che contiene `:` (i flussi NTFS, `state::$DATA`), è rifiutata; - passare da collegamenti: un percorso che attraversa un collegamento simbolico (symlink) o una giunzione di Windows dentro la cartella dell'app è rifiutato, in lettura e in scrittura, anche se porta dentro la cartella (per esempio a `app.json` o a `state\`). Fa eccezione solo la cartella `state\` stessa: l'IT la può sostituire con una giunzione per tenere i dati dell'app su un altro disco, e per gli script resta la solita `state\`; i collegamenti più in basso, dentro `state\`, sono rifiutati. Gli script non possono creare collegamenti e l'app non ne ha bisogno: non metterne nel pacchetto («Carica app» li rifiuta comunque); - raggiungere le porte interne delle altre app e le chiavi del motore (non sono nelle variabili d'ambiente); - chiamare indirizzi di rete che non sono dichiarati in `network` (sezione 8.1), e chiamare il motore o un'altra app dello stesso server (sezione 8.1); - `$apis.static` su una cartella fuori dalla cartella dell'app; `$app.onServe()`, `$app.restart()`, `$app.restoreBackup()`, `$app.rootCmd`, `unbind`, `unbindAll`, `bind` sugli agganci dell'app, `onSend` del client di posta e `$app.unsafeWithoutHooks()` (questi nomi per gli script non esistono: usarne uno dà un errore); percorsi che cominciano con una barra (`/release/x.json` è la radice del disco, rifiutato: scrivi `release/x.json`). La scheda `app.json` la cambia solo l'IT (un Proprietario, dalla scheda Configurazione della console del motore, oppure a mano): uno script non può darsi da solo un altro accesso, altri ruoli o altri strumenti. Inoltre, per regola: 1. Niente chiamate a servizi esterni o a Internet se l'IT non le chiede. Ogni indirizzo che gli script chiamano si dichiara in `app.json`, campo `network` (sezione 8.1): il motore blocca gli altri. Un sistema aziendale in rete locale si usa solo se l'IT lo chiede. 2. Niente scambio di dati con altre app attraverso i loro file o le loro porte: se serve, chiedilo all'IT. 3. Se ti serve un lavoro pesante (leggere Excel, creare PDF, mandare email, ridurre immagini…), usa lo strumento del motore (sezione 11). Se non c'è, non inventarne uno con trucchi: scrivi nelle note di consegna cosa servirebbe. ### 8.1 Rete in uscita (`network`) Ogni chiamata di rete degli script (`$http.send`, `$filesystem.fileFromURL`, `$filesystem.s3`, e quelle che PocketBase fa per l'app: archivio S3, backup, OAuth2) passa dal motore, che permette solo gli indirizzi dichiarati in `app.json`: ```json "network": { "allow": ["api.openai.com", "*.example.com", "erp.example.local:8443"], "allowHttp": ["old-erp.example.local"] } ``` - Una voce è un nome o un indirizzo IP, con la porta se serve (senza: 443 per https, 80 per http). `*.example.com` vale per i nomi sotto `example.com` e solo per indirizzi pubblici (almeno due pezzi dopo l'`*`: `*.com` non vale). Una voce sbagliata è ignorata e segnalata da «Carica app». Niente `*` da solo, niente `https://`, niente percorsi. - Solo https; l'http semplice solo per `allowHttp`. - Il motore controlla l'indirizzo IP vero dopo la risoluzione DNS: un server della rete dell'azienda si raggiunge solo con una voce esatta (senza `*`); la macchina stessa (`127.0.0.1`, `::1`) e `169.254.x` solo con una voce scritta come indirizzo IP, mai attraverso un nome come `localhost`; il motore stesso e le altre app di questo server non si raggiungono mai da uno script, nemmeno con una voce (`403`, codice `app_origin`). - Anche il server di posta dell'app (SMTP: `$app.newMailClient()`, `$mails.*`) è un indirizzo: dichiaralo con la porta, per esempio `"smtp.example.com:587"`. - Redirect solo verso indirizzi dichiarati (al massimo 9); risposte fino a 32 MB; al massimo 300 secondi per chiamata (`timeout`, senza: 120). - Una chiamata non permessa dà un errore in inglese che dice cosa dichiarare. Il motore registra ogni chiamata (mai il contenuto) nella scheda «Rete in uscita» dell'app; l'IT approva gli indirizzi in «Carica app» e li può cambiare da quella scheda. - Il modo della porta lo decide l'IT, nel registro del motore (non in `app.json`): *enforce* (Blocca, il modo di partenza) ferma quello che non è dichiarato, *warn* (Avvisa) lo registra soltanto. - Scrivi in `DELIVERY.md` a cosa serve ogni indirizzo. **Modelli AI.** Le app chiamano i modelli AI (OpenAI, Anthropic, Gemini, Mistral, Azure OpenAI, OpenRouter, Groq, DeepSeek…) **solo dagli script**, con `$http.send` e l'indirizzo del fornitore in `network`, **mai dal browser**: in una pagina la chiave sarebbe visibile a chiunque. La pagina chiama una rotta dell'app (`routerAdd` con `$apis.requireAuth()`), che chiama il modello. La chiave non sta mai nel codice, in `app.json` o nelle pagine: chiedi all'IT dove la tiene. Il motore legge dalle risposte il modello e i token (con OpenAI Chat Completions in streaming, chiedi `stream_options: { include_usage: true }`) e mostra consumi e costi stimati nella pagina «AI» della console; non registra mai i messaggi. Il testo completo, con un esempio, è nel capitolo «Strumenti del motore» (`pyrite.exe tools --md --lang it`). **Gli script non chiamano il motore né le altre app.** Uno script non può chiamare con `$http.send` (o con `$filesystem.fileFromURL`) il motore (console, portale, `/api/platform/…`) né un'altra app dello stesso server, nemmeno dichiarandone l'indirizzo in `network`: il motore riconosce le chiamate che partono dalle app e risponde `403` con `"code": "app_origin"`, e l'IT lo vede negli eventi. Resta permesso chiamare **la propria app** (la sua porta o il suo nome): la chiamata vale come una richiesta arrivata dalla rete, quindi senza i permessi di chi siede al server (chiamata come `http://127.0.0.1:PORTA` è servita in chiaro anche con l'HTTPS acceso; chiamata con il nome o l'indirizzo di rete del server riceve il rimando a https). Resta permesso chiamare un altro programma che gira sullo stesso server (per esempio un modello AI locale), dichiarato in `network` come indirizzo IP con la porta: riceve l'intestazione `X-Pyrite-App`, che può ignorare. Un'intestazione `X-Pyrite-App` scritta dallo script si toglie sempre. Se due app devono scambiarsi dati, chiedilo all'IT e scrivilo in `DELIVERY.md`. **Agganci.** Negli script non esistono `unbind`, `unbindAll` e `bind` degli agganci dell'app, `onSend` del client di posta e `$app.unsafeWithoutHooks()`: i gestori del motore non si tolgono e non si saltano. Per aggiungere un gestore usa le funzioni di sempre (`onRecordCreate`, `onMailerSend`…) o `bindFunc`. **Il limite del recinto.** Tutto questo è una difesa dentro il programma: lo script gira nello stesso processo del motore dell'app, con lo stesso utente di Windows, quindi riduce molto quello che uno script può fare ma non è una garanzia piena contro uno script scritto apposta per uscirne. La garanzia piena per le app di terzi (un utente di Windows per ogni app) è prevista per una versione futura. Fino ad allora non contare solo sul recinto per script scritti da qualcuno di cui l'IT non si fida. ## 9. Le pagine (`release\pb_public\`) Il motore serve le pagine sulla porta dell'app, dallo stesso indirizzo delle API: `http://SERVER:8105/` per le pagine e `http://SERVER:8105/api/...` per i dati. In un'app PocketBase un percorso che non ha un file in `pb_public` risponde con `index.html` (le app a pagina unica funzionano con qualunque percorso). 1. Percorsi sempre relativi (`style.css`, `lib/pocketbase.umd.js`, `/api/...`). Mai indirizzi o porte scritti nel codice, e mai `http://` scritto a mano: la stessa app può essere servita in http o in https (lo decide l'IT, dalla pagina Certificati del motore). 2. **Nessuna risorsa da Internet**: niente CDN, Google Fonts, librerie remote, immagini esterne. Tutto quello che serve sta in `pb_public` (o in `public` per le app statiche). Caratteri: quelli di sistema (`system-ui, "Segoe UI", sans-serif`). 3. SDK di PocketBase: una copia locale di `pocketbase.umd.js` in `pb_public\lib\`, la stessa dell'app di esempio (è dentro l'esempio scaricabile, con la sua licenza). Si crea con `const pb = new PocketBase("/");` e trova da solo l'accesso consegnato dal motore. 4. Il motore inserisce da sé, nelle pagine HTML delle app con login, lo script dell'accesso unico (`/__platform/sso.js`) e, nelle pagine di ogni app PocketBase, lo script della protezione dalle modifiche concorrenti (`/__platform/concurrency.js`, regola 10): non aggiungerli e non toglierli. Se una chiamata risponde `401`, ricarica la pagina (`location.reload()`): il motore rifà l'accesso. Vale per tutte le chiamate, anche per quelle dei moduli (salvataggi, caricamenti di file), non solo per l'elenco. 5. Collegamento per tornare al portale: `← Portale aziendale` (nella lingua dell'app). Nessun pulsante «Esci». 6. I percorsi che iniziano con `/__platform/`, `/_/`, `/api/platform/` e `/api/tools/` sono del motore: non usarli per altro. Sulla porta dell'app `/_/` rimanda alla console del motore e `/api/collections/_superusers/` è chiuso (403): il pannello di PocketBase e l'accesso da superuser lì non ci sono. `/__platform/info` restituisce in JSON l'identificativo dell'app, il nome e l'indirizzo del portale (`{ app, name, portal }`); `/__platform/tools.js` è l'aiuto per gli strumenti dalle pagine (regola 11). 7. Mostra i messaggi d'errore che arrivano dal server (`err.response.message` con l'SDK): le rotte dell'app li scrivono nella lingua della richiesta (sezione 10); quelli di PocketBase stesso (validazione dei campi) sono in inglese. 8. Nascondere un pulsante a chi non ha il ruolo è solo comodità: la protezione vera sono le regole (sezione 6) e le rotte. 9. Pagine semplici, leggibili su schermo piccolo: a 390 px di larghezza la pagina non scorre di lato, e una tabella larga sta in un contenitore che scorre da solo (`overflow-x: auto`). Tema chiaro e scuro (`color-scheme: light dark` e `prefers-color-scheme`), un'etichetta su ogni campo (`