--- name: pyrite-app description: Standard for building and delivering apps for Pyrite, the company engine that hosts department apps on PocketBase 0.40.6. Use it whenever you create or change an app for Pyrite - folder structure, app.json, users and roles, API rules, server JS scripts, the $orch engine tools (also from the pages, with toolAccess), concurrent changes, outbound network, scheduled jobs, languages, testing, the zip package for "Upload app" and the delivery notes. --- # Agent standard · Pyrite Standard version: 0.7.7 (alpha). Engine: Pyrite 0.7.7 with PocketBase 0.40.6. An Italian translation of this standard exists (`AGENT-STANDARD.it.md`): the two say the same thing, and this English text is the reference. Some technical names use the `orch` prefix: `$orch` in the scripts, the `orch_*` collections and cookies and the `ORCH_*` variables; the engine APIs are under `/api/platform/`. **What changes in 0.7.5**, for whoever knows the 0.7.4 standard: - the app can be reached in more ways, and none of them must matter to it: the usual `http://SERVER:PORT/`, or its **own name** (for example `https://booking.app.example.com/`), or from the internet if an Owner opens it (section 1.1). No fixed host or port in the pages, in the scripts or in the settings; - each app is “Local network only” or “Internet too”: an Owner decides, an Admin asks. It is not written in `app.json` (section 1.1); - the engine writes the **real address of the caller** in `X-Forwarded-For` and `X-Pyrite-Ip`; the same headers written by the client never arrive (section 1.1); - the events of the engine, also those that come from the apps (the tools, the Data tab), go to the **engine history** with who did them; the **change history** (`history`) now has the person's name also for the changes made by a route written by the app. The apps don't have to be changed (section 1.1). **What changes in 0.7.7**, for whoever knows the 0.7.5 standard: the `port` of `app.json` is a request (whoever uploads the app can choose another one, and after the installation the port belongs to IT: an update never changes it, and going back to the previous version puts back the port it had before); an `app.json` with two keys equal apart from upper and lower case is refused; an app uploaded by an Admin warns the Owners by email, and an Owner can reject it with a reason. This document is written for you, the AI agent that builds an app for a department. Read all of it before writing code. The numbered rules are mandatory: if the department's request contradicts them, don't work around them, write it in the delivery notes (section 15). The examples come from the example app “Parts price list” (`parts-pricelist`), tested in the engine and downloadable together with this document; the other code examples are tested in the same engine. ## 1. The context Pyrite is a Windows program (`pyrite.exe`) that runs on a company server, in the local network: people use the apps at addresses such as `http://SERVER:8105/`, or `https://SERVER:8105/` when IT turns HTTPS on (one certificate for the engine and every app port), or with a name for each app; IT can also open some apps to the internet (section 1.1). The engine: - hosts the departments' apps, each in its own folder and, if it has data, with its own separate PocketBase 0.40.6 (own process and database); - is the front door: it decides who enters each app (single sign-on from the company portal), counts the requests, shows a clear page when the app is stopped; - makes backups every night, restores them, restarts apps that crash, collects measures and logs; - offers a toolbox of ready-made **tools** for the heavy jobs (import and export of Excel and CSV files, PDF documents, photos, sequence numbers, change history, statistics, scheduled jobs), usable from the app's scripts with `$orch` and, when the app allows it in `toolAccess`, from its pages (section 11); - protects the records from concurrent changes and lets the scripts reach only the network addresses the app declares; - speaks English and Italian: the engine's console, the portal and the engine's messages are in both languages, English first. So you **must not** build: a server, logins, sign-up pages, password management, backups, HTTPS, automatic start, log shipping, libraries for Excel, PDF or images (in the scripts or in the pages: the engine tools do it). You build only the app: data, rules, scripts and pages. The IT person who runs the engine is not a programmer: everything you deliver must work without manual steps, with explanations in plain words. IT installs your app from a zip file with the console page “Upload app”, reviews it and starts it. In the engine console IT people have a level: an **Owner** can do everything; an **Admin** manages only the apps an Owner assigns to them, and an app uploaded by an Admin waits for an Owner's approval before it can start. ### 1.1 How people reach the app 1. **The address.** The people reach the app at `http://SERVER:PORT/` (or `https://`, when HTTPS is on). If IT chooses names, each app also has its own name, under the domain of Pyrite (for example `https://booking.app.example.com/`), and the portal has its own. Whoever opens an app by name without being signed in goes through the portal, signs in once and comes back to the app by itself; the engine uses no cookie valid for the whole domain. IT can also close the ports of the apps to the network: then people reach the apps only through the engine, with the names. So write in the pages relative paths (`/api/…`, `/__platform/portal`) and never a fixed host or port, in the pages or in the scripts. 2. **Internet.** In the engine each app is “Local network only” or “Internet too” (tab **Exposure** of the app). New apps start as “Local network only”. An Owner decides; an Admin of the app can take it off the internet on their own and, to open it, asks an Owner (the request appears in “To look at” and the history keeps who asked and who decided). An app seen from the internet always opens after the sign-in to the portal. It is not a field of `app.json`: if the app is meant to be used from outside, say it in `DELIVERY.md`, so that IT knows what to decide. 3. **The address of the caller.** The engine writes the real address of whoever calls in `X-Forwarded-For` and in `X-Pyrite-Ip`, and the app's PocketBase believes only `X-Forwarded-For` (in a route, `e.realIP()`): behind a tunnel or a proxy it is the address of the person, not the one of the tunnel. The headers `X-Forwarded-For`, `X-Forwarded-Proto`, `X-Pyrite-Ip` and `X-Orch-Token` written by the client never reach the app. `CF-Connecting-IP`, `X-Real-IP` and `True-Client-IP` are written by the engine too, with the real address, and the other headers that carry a client address (`X-Client-IP`, `Fastly-Client-IP`, `Forwarded`…) are removed: what the client writes in them never reaches the app. 4. **The links in the emails.** For the links in the emails the app sends (address check, forgotten password), the engine writes the right address in the app's settings at every start, and again without a restart when the names change: don't write it yourself, it would be replaced. 5. **The engine history.** The events of the engine (who started an app, who gave an access, who signed in, an uploaded file, a restored record…) are in the **engine history**, a file of its own that nobody deletes or changes from the console. An event that comes from an app (a tool, the Data tab) says who did it, if the person really exists and, in an app with login, has access. The **change history** of the records (`history`, section 11) now shows the person's name also for the changes made by a route written by the app, when a signed-in person calls it; the scheduled jobs of the app and the calls without a login stay “app script”. Nothing to change in the apps. ## 2. What an app looks like An app is a folder. The folder name is the identifier of the app (`id` in `app.json`). ```text parts-pricelist\ the app folder (= id) ├── app.json the card: kind, port, access, roles, tools ├── DELIVERY.md notes for IT (section 15) └── release\ the code: it is replaced without touching the data ├── pb_migrations\ database schema and initial data (JavaScript) ├── pb_hooks\ server-side rules and routes (JavaScript, *.pb.js files) │ └── lib\ shared code, loaded with require() ├── pb_public\ the pages: HTML, CSS, JS, images │ └── lib\ the PocketBase SDK (pocketbase.umd.js), local copy ├── templates\ PDF templates (.json, .html) and their images, only with the pdf tool ├── import\ import recipes of the app (`*.json`), only with the import tool └── seed\ initial data files (CSV, Excel, JSON), if needed ``` The engine adds by itself, next to `release\`, the folders `state\` (the data: `state\pb_data`) and `logs\`. They are **not** part of the app you deliver. The app's scripts write files only inside `state\`: for them `app.json`, `release\` and `logs\` are read-only (section 8). The backups of the app (and their copy to the network folder) contain only `state\pb_data`: a file that must last goes in a collection (file field), not elsewhere in `state\`, where a restore does not bring it back. A **static** app (only pages, no database) has only `app.json`, the delivery notes and `release\public\` (careful: `public`, not `pb_public`). ## 3. The app.json card Complete example (the example app): ```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"] } ``` | field | values | meaning | |---|---|---| | `id` | 3 to 32 characters: lowercase letters, digits, dashes (not at the start or the end) | stable identifier; the same as the folder name. Reserved: `api`, `apps`, `console`, `admin`, `portale`, `accedi`, `archivio`, `control`, `platform`, `www`, and the names of the Windows devices (`con`, `prn`, `aux`, `nul`, `com0` to `com9`, `lpt0` to `lpt9`) | | `name` | text, required | the name people see in the portal | | `description` | text | one line that says what it is for | | `kind` | `static`, `pocketbase`, `pocketbase-cron`; required | the kind (section 4) | | `access` | `login` or `public` | `login`: only who has been given access by IT gets in; `public`: anyone in the company network. Case does not matter. If missing, `login`; any other value is not refused: the engine warns and treats the app as `login` | | `port` | 1024 to 64000, required | the public port of the app. **IT decides it**: use the one given in the request; if there is none, put `8199` and write it in the delivery notes. The engine also uses the port + 1000. **The port is a request**: whoever uploads the app can choose another one if this one is busy or closed by the Windows firewall, and the engine writes the chosen port in the installed `app.json`. **After the installation the port belongs to IT**: an update of the app does not change it (the app keeps the port it has, even if the new `app.json` asks for another one; the port is changed in the Configuration tab). So neither the app nor its documents (delivery notes, README, links, scripts) may rely on an address with a fixed port: take the port from the request, never write it into other files, and let the app answer on whatever port the engine gives it. | | `version` | for example `0.1.0` | the version of the code: raise it at every delivery | | `owner` | text | the department or the reference person | | `color` | hex color, for example `#126a61` | optional | | `memoryLimitMB` | whole number | optional; indicative memory limit. Don't set it unless asked | | `auth` | object | PocketBase apps: users and roles (section 5). In a public app it only declares the roles used by `toolAccess` | | `auth.collection` | collection name, usually `users` | the users collection of the app; if missing, `users` | | `auth.roleField` | field name | the user field that holds the role. Required whenever `auth.roles` is set: there is no default, and without it the engine does not read or write the roles (and warns IT). Call it `role`, as the engine does for the apps it creates | | `auth.roles` | list | the roles, **from the most to the least powerful** | | `auth.defaultRole` | one of the roles | the base level: the role of whoever enters for the first time. If missing, the least powerful | | `auth.extra` | object | fixed fields the engine writes on the user at every sign-in, new or existing user (rare) | | `tools` | list, for example `["import"]` | the engine tools used by the scripts and by the pages (section 11). Only the ones really used. | | `toolAccess` | object, for example `{ "export": { "pages": true, "minRole": "editor", "collections": ["orders"] } }` | which tools the **pages** may use, and who (section 11, rule 8). Closed by default: without an entry the pages can't use any tool. Entries: `pages`, `minRole`, `destructiveRole`, `collections`, `maxRows`, `guests` (public apps only), `recipes` (import), `snapshots` (stats), `templates` and `maxPages` (pdf), `keyFields` (images: the fields that may receive or match the file name, for example `["products.code"]`; without it, none). IT sees them at “Upload app” and in the “Tools for pages” tab | | `toolJobs` | `{ "heavy": 1 }`, from 1 to 4 | how many long jobs started from the pages run at the same time; default 1 | | `concurrency` | `"strict"` (default), `"warn"`, `"off"` (case does not matter; any other value means strict) | the protection from concurrent changes (section 9, rule 10). For a new app leave it out: strict | | `numbering` | object | the counters of the `numbering` tool (section 11) | | `history` | object, for example `{ "collections": ["orders"] }` | the collections that keep the change history (`history` tool, section 11) | | `images` | object, for example `{ "products.photo": { "maxSide": 1600 } }` | the rules of the `images` tool for the photo fields (section 11) | | `network` | object, for example `{ "allow": ["api.openai.com"] }` | the addresses the scripts may call (section 8.1). `allow`: over https; `allowHttp`: also over plain http, only for internal systems without https. Without `network` the scripts can't call any address | | `import` | object, for example `{ "consoleCollections": ["suppliers", "orders"] }` | `consoleCollections` lists the only collections in which the “Import” tab of the console may import (and undo); `[]` = the console imports nothing; without it, all of them. Advised for an app that keeps its data in order with its own routes or request hooks (movements, histories, states). Section 11, “Import” | `name` and `description` are a single text: write them in the main language of the people who use the app (the portal shows them as they are). Role names: lowercase letters, digits and underscore, 2 to 30 characters (for example `manager`, `workshop_head`). The engine refuses the card (and so the app) if it isn't valid JSON, if `kind` isn't one of the three values, if `name` is missing, if `port` is out of range or already used (also port + 1000), if `defaultRole` isn't among the roles, if the `id` is invalid, reserved or already registered (if `id` is missing, the name of the folder is used; in a zip the app must then be inside a single folder). “Upload app” applies the same checks before installing (section 15). ## 4. Choosing the kind of app | kind | when | what is in it | |---|---|---| | `static` | only pages: a calculator, a guide, a list read from a file delivered with the app | `release\public\`. No database, no server-side scripts. With `access: "login"` the front door asks for access, but the page doesn't know who the person is | | `pocketbase` | almost always: data to save, users with roles, rules | a whole PocketBase 0.40.6 for the app: SQLite database, REST and realtime API, migrations, JS hooks, pages in `pb_public` | | `pocketbase-cron` | like `pocketbase`, with scheduled jobs (`cronAdd`) | as above; the kind tells IT that the app also works on its own | If the app must remember something (even just shared preferences or an editable list), it is `pocketbase`. Don't save shared data in the browser's `localStorage`. ## 5. Users, access and roles The principle: **who enters is decided by IT, centrally, and people enter only from the portal; the role inside the app is managed by the engine together with the app.** 1. In apps with `access: "login"` the person enters from the company portal with the credentials of the central directory. The engine creates in the app's `users` collection a user with the email and the name of the person and **links it to the person by ID**: when IT changes the email of a person, or the person changes the displayed name in the profile, the engine aligns the app user, which stays the same record with the same `id`. The engine issues a session and puts it in the browser (key `pocketbase_auth` of `localStorage`, the one the PocketBase SDK uses by itself). When the page opens, the user is already signed in: `pb.authStore.record`. 2. The app **must not** have: login pages, sign-up, “forgot password”, change password, a “Sign out” button. Passwords belong to the directory: the engine applies the password rules (uppercase, lowercase, digit, special character, minimum length set by IT) and the mandatory change at the first access. To go back to the portal use the link `/__platform/portal`; people sign out from the portal. 3. The identity is locked by the engine: from the app's APIs no one can create users or change email, name or password. Don't try, and don't show fields for it. To say whose a record is, use the `id` of the user (a `relation` field to `users`), never the email: the email can change, the `id` stays. The person has a profile page in the engine (`/profile/`, from the portal): displayed name, language, theme, password and the open sessions, with “Sign out of all sessions”, which also closes the sessions in the apps (the next call of the page answers `401`: section 9, rule 4). The app doesn't need to offer any of this. 4. The **role** is a `select` field of the users collection, called `role` (declared in `auth.roleField`), with the same values as `auth.roles`; when the app runs in the engine, the values of `auth.roles` are also accepted by the field. At the first entry (and whenever the user's role is empty) the engine writes the base level (`auth.defaultRole`); otherwise it doesn't touch it. The role is changed: - by IT, from the engine console (app page, “Access and roles” tab: an Owner, or an Admin who manages the app); - if needed, by whoever has the highest role, from a page of the app, if the `updateRule` of `users` allows it; when `toolAccess` uses `minRole` or `destructiveRole`, nobody can give a role more powerful than their own. 5. In apps with login nobody can change their **own** role: the engine blocks it (not in the local test of section 12.1, where the engine is missing). In public apps there is no such block: only when `toolAccess` uses `minRole` or `destructiveRole` the engine stops anyone from giving a role more powerful than their own (lowering is allowed); otherwise the `updateRule` of `users` decides. 6. The role field is created by a migration (section 7.3). The rules read it as `@request.auth.role`. 7. In apps with `access: "public"` anyone in the company network opens the pages without signing in: the requests are anonymous. **No app has free sign-up, not even the public ones**: who has a user in an app is decided by IT: in apps with login with the people and groups of the engine, in public apps by creating them in the app's Data tab (section 13). If a public app needs users (for example the few people who may write), they are created by IT or by the app's scripts (never in the migrations: rule 8), never by a sign-up page: the engine refuses every API request that creates a record in any auth collection (not only `users`; OAuth2 and `/api/batch` included): with `403` in public apps, with `400` in the `users` collection of an app with login; only a superuser of the app, that is the panel the engine opens, can create users and warns IT if a collection asks for it with its `createRule`. Keep the `createRule` of `users` at `null` (section 6, rule 5). Don't put confidential data in a public app and don't allow anonymous writes unless they are really the purpose of the app (for example a report form); in that case agree it with IT and write it in the notes. 8. Don't create users, test accounts or administrators in the migrations, and don't write passwords anywhere. ## 6. API rules (mandatory) PocketBase exposes every collection as a REST API. Who can read and write is decided **only** by the rules of the collection (`listRule`, `viewRule`, `createRule`, `updateRule`, `deleteRule`), checked by the server. Pages protect nothing: anyone with access to the app can call the APIs directly. 1. **Every collection has the five rules written explicitly in the migration that creates it.** Never leave them at the starting value. 2. `null` means “nobody” (only the engine and IT). `""` (empty text) means “**anyone**, even without login”. **Never `""` on `createRule`, `updateRule`, `deleteRule`.** `""` on `listRule`/`viewRule` only in a public app and only for really public data. 3. **Every rule of an app with login starts with `@request.auth.id != ''`.** Without login `@request.auth.id` is `''`: a rule like `requester = @request.auth.id` becomes true for all the records with an empty `requester`, and those records become readable and writable by anyone. The right form is: ```js listRule: "@request.auth.id != '' && (@request.auth.role = 'buyer' || requester = @request.auth.id)", ``` 4. Fields decided by the server (author, approval status, sequence numbers, validation dates) are not accepted from the client: either a hook sets them (section 7.1), or the rule forbids them with `@request.body.field:isset = false`. 5. The app's `users` collection has strict rules, in every app, public ones included: `createRule: null` (no sign-up), `deleteRule: null`, reading one's own user (and, if needed, the colleagues for whoever has the highest role), `updateRule` only for who may change the others' roles, or `null`. 6. The routes you write (`routerAdd`) do **not** go through the collection rules: protect them with `$apis.requireAuth("users")` and check the role in the code. In the same way, `e.app.save(...)` in scripts writes without rules: the script is the server. The `$orch` tools called by the scripts also write as the server; called by the pages (`/api/tools/`), they apply the rules of the app for the person and the permissions of `toolAccess` (section 11, rules 6 and 8). 7. Test the rules for real: without login, with every role, calling the APIs directly (section 12). Typical rules (roles `manager` and `storekeeper`, field `role`): | what | rule | |---|---| | everyone who enters reads | `@request.auth.id != ''` | | only the manager writes | `@request.auth.id != '' && @request.auth.role = 'manager'` | | everyone sees their own records | `@request.auth.id != '' && author = @request.auth.id` | | one's own record while it's a draft, without touching the status | `@request.auth.id != '' && author = @request.auth.id && status = 'draft' && @request.body.status:isset = false` | | nobody from the API (only scripts and IT) | `null` | The migration of the example app that creates the role field, the collections and their rules: ```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. Server-side JS scripts (PocketBase 0.40.6) The scripts run inside the app's PocketBase, with the goja JavaScript engine: modern JavaScript, but without Node.js, without `npm`, without `fetch` and without `async`/`await` (handlers are synchronous). Use only the PocketBase 0.40.6 APIs; don't copy examples of old versions (before 0.23 the JS APIs were different: no `$app.dao()`, no `onRecordBeforeCreateRequest`). ### 7.1 Hooks and routes (`release\pb_hooks\*.pb.js`) PocketBase loads only the files ending with `.pb.js`, in alphabetical order, when the app starts: after a change the app must be restarted. 1. **Every handler runs in a separate JS machine**: it doesn't see variables, constants or functions written outside the handler, in the same file. Shared code goes in a module (for example `pb_hooks\lib\rules.js`, without `.pb.js`) and is loaded **inside** the handler with `require(__hooks + "/lib/rules.js")`. Inside the module, the module's own constants and functions are visible. 2. In record and request hooks always call `e.next()` to go on; to block, throw an error: `throw new BadRequestError("message for the person")` (also `ForbiddenError`, `NotFoundError`, `UnauthorizedError`). 3. Use `e.app` inside handlers (it is the right app even inside a transaction); `$app` is fine in routes and `cronAdd` jobs. The `$orch` tools use the right app by themselves (section 11, rule 5). 4. `e.auth` is the user making the request (or `null`): `e.auth.getString("role")`, `e.auth.email()`, `e.auth.id`. 5. Request data: `e.requestInfo().body`, `e.request.pathValue("id")`, `e.request.url.query().get("x")`, `e.request.header.get("Accept-Language")`. Answers: `e.json(200, object)`, `e.string(200, text)`, `e.noContent(204)`. 6. SQL only with bound parameters, never by concatenating texts: `e.app.db().newQuery("... WHERE x = {:x}").bind({ x: value })`. To read the rows: `arrayOf(new DynamicModel({ text: "", whole: 0, decimal: -0, flag: false }))` (for numbers with decimals the shape is `-0`, not `0`). 7. Find records with filters and parameters: `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)`. Go through the results with `for (const r of records)`. 8. Several writes that must succeed together: `e.app.runInTransaction((txApp) => { ... txApp.save(r) ... })`, using `txApp` inside. 9. Dates are saved in UTC (`new DateTime()` is now); the format is handled in the page. 10. `console.log` goes to the output of the process, which IT sees in the engine console (Status tab → “Process output”). Don't write personal data there. 11. **The prefix `/api/tools/` belongs to the engine** (the tools from the pages): never register routes there. “Upload app” refuses a new package that registers a route written under `/api/tools/` (a route with a placeholder that could also match there, such as `/api/{coll}/{id}/approve`, is only a warning, and at start-up it leaves the app's pages without the tools); an app already installed that has them keeps its routes, but its pages can't use the engine tools until the routes move. Put the app's routes under a name of the app, for example `/api/pricelist/…` as in the example. 12. Saves made by the scripts aren't subject to the protection from concurrent changes (section 9, rule 10): if a route changes a record on behalf of a person after showing it to her, compare the version yourself or use the pages. Example (the example app): fields decided by the server, a protected route that uses an engine tool, the undo, and a summary with 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")); ``` The shared module `pb_hooks\lib\rules.js`, with the texts in the two languages of the app and the recipe built from the supplier's file: ```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 }; ``` Scheduled jobs (`kind: "pocketbase-cron"`), in a `.pb.js` file: ```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); } }); ``` The engine records every run of every job (start, duration, result, error), lets IT pause, resume and “Run now” from the console, and writes an event when a job starts failing. So: - give every job a **stable, readable name** (the first argument of `cronAdd`, lowercase letters, digits and dashes): it is the key of the history and of the pause; - the schedule is in **UTC**, as in PocketBase (`"0 7 * * 1-5"` is 07:00 UTC, 08:00 or 09:00 in Italy); - let the errors out of the function (or throw your own `new Error("…")`): an empty `catch` makes a failed job look fine; - **a restart of the app interrupts the runs in progress**: the one asked by IT and also the automatic one after a change of the internal port, the memory limit, `access`, the `auth` roles/collection/field or the network mode (changes to `network`, `toolAccess`, `concurrency` are read again without a restart). The run stays in the history as “interrupted”. Write long jobs so that they can be redone, in pieces, never all or nothing. Details in section 11, “Scheduled jobs”. **Slow routes.** The engine waits at most **4 minutes 50 seconds** for the answer of a route of your scripts (the limit of PocketBase itself is 5 minutes). If the route has not answered by then, the caller gets `504` with a clear message; if the app closes the connection without answering (also suddenly, for example when the app's process stops because of a fault), `502`. A stopped or restarting app gives `503`. The engine **never repeats** a request that was already sent: the route may have finished its work all the same, so check before trying again (a second click must not create a second record). If the caller goes away before the answer, the engine counts the request but not as done or as an error (status `499` in its own counters). A `504` or `502` leaves a warning in the engine events. So don't write routes that take minutes: for long work start a job (section 11, `jobs`) and answer at once. ### 7.2 Migrations (`release\pb_migrations\`) 1. The database schema changes **only** with migrations: files `_.js`, applied once, in name order. Use a Unix timestamp as the number (for example `1793600000_parts.js`) and make the number grow with every new file. 2. A migration already delivered is **never changed, renamed or removed** (section 15, point 8): to change the schema add a new file. Always write the function that goes back too (second argument of `migrate`). 3. In migrations use the `app` you receive (`migrate((app) => { ... })`), not `$app`. Constants written outside `migrate` can be used. 4. Collections with `new Collection({ type: "base", name, listRule, viewRule, createRule, updateRule, deleteRule, fields: [...], indexes: [...] })` and `app.save(collection)`. Field types: `text`, `editor`, `number`, `bool`, `email`, `url`, `date`, `autodate`, `select`, `file`, `relation`, `json`, `geoPoint`. Always add `created` and `updated` of type `autodate` (sorting by `-created` needs them). 5. Don't touch the system collections (`_superusers`, `_mfas`, `_otps`, `_externalAuths`, `_authOrigins`) or the engine's ones (`orch_*`). Of the `users` collection, change only the rules and add your own fields (the role, the department…). 6. Initial data: from files in `release\seed\` (CSV or Excel with the `import` tool, section 11; JSON with `$os.readFile` and `JSON.parse(toString(...))`). Never real personal data without IT's agreement, never users or passwords. Example (the example app): initial data from a CSV delivered with the 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(); }); ``` Example: initial data from a JSON file. ```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 The role field If the app has roles, the first migration adds to the users collection the field declared in `auth.roleField` (`role`), with the same values as `auth.roles`, and sets the rules of `users` (see the example in section 6). ### 7.4 People's data in the package A package carries only the data the app needs to start. People's data (customers, employees, suppliers with name and address) is something else and has its own rules. 1. **In the package** goes only lists, service tables and a few made-up examples (in the example app: the initial price list and the departments, section 7.2, point 6). 2. **A real archive of people's data is not locked inside the zip.** IT loads it after the installation, with the engine's import tool (the console's “Import” tab, or `$orch.import`), from a file that stays where IT decides. In the package put at most the import recipe (`release\import\*.json`) and, in `DELIVERY.md`, the description of the expected columns. 3. **Initial data is imported with the engine's tool** (`$orch.import.load`, as in the example of section 7.2), never with direct SQL or a row-by-row save written by hand. This way every import stays in the engine's import history and goes through the checks (dry run, keys, relations). For a small service list in JSON the example of section 7.2 is fine. 4. **`DELIVERY.md` has a mandatory “Personal data” line**: which people's data there is, where it comes from and which roles see it. If there is none, write “none”. 5. The final checklist (section 14) has the line that checks it. ## 8. What a script cannot do (the jail) The scripts of each app are closed inside the app folder. The engine blocks, with an error in English: - starting programs (`$os.cmd`, `$os.exec`) and closing the process (`$os.exit`); - reading files outside the app folder (`$os.readFile`, `$filepath.walk`, `$filepath.glob`, `$filesystem.fileFromPath`, `$template.loadFiles`, `require` with paths that leave, even inside a module); - writing, renaming or deleting files outside `state\` (`$os.writeFile`, `$os.mkdir`, `$os.rename`, `$os.remove` and the like): `app.json`, `release\` and `logs\` can only be read by the scripts. If the app has to save a file of its own (for example a temporary file or an export), it puts it in `state\`; files uploaded by people go in a file field of a collection; - using the database to open or write other files: `ATTACH`, `DETACH`, `VACUUM INTO` outside `state\`, extensions (`load_extension`); - using names that Windows reads as another name: a part of the path that ends with a dot or a space (`state.`), or that contains `:` (NTFS streams, `state::$DATA`), is refused; - going through links: a path that crosses a symbolic link (symlink) or a Windows junction inside the app folder is refused, for reading and for writing, even if it leads back inside the folder (for example to `app.json` or to `state\`). The only exception is the `state\` folder itself: IT may replace it with a junction to keep the app's data on another disk, and for the scripts it remains the usual `state\`; links further down, inside `state\`, are refused. Scripts can't create links and the app doesn't need them: don't put any in the package (“Upload app” refuses them anyway); - reaching the internal ports of the other apps and the engine's keys (they are not in the environment variables); - calling network addresses that aren't declared in `network` (section 8.1), and calling the engine or another app of the same server (section 8.1); - `$apis.static` on a folder outside the app folder; `$app.onServe()`, `$app.restart()`, `$app.restoreBackup()`, `$app.rootCmd`, `unbind`, `unbindAll`, `bind` on the app's hooks, `onSend` of the mail client and `$app.unsafeWithoutHooks()` (these names do not exist for the scripts: using one gives an error); paths that start with a slash (`/release/x.json` is the root of the disk, refused: write `release/x.json`). The `app.json` card is changed only by IT (an Owner, from the Configuration tab of the engine console, or by hand): a script can't give itself another access, other roles or other tools. Also, as rules: 1. No calls to external services or to the Internet unless IT asks for them. Every address the scripts call is declared in `app.json`, field `network` (section 8.1): the engine blocks the others. A company system in the local network is used only if IT asks for it. 2. No data exchange with other apps through their files or their ports: if it's needed, ask IT. 3. If you need a heavy job (reading Excel, creating PDFs, sending emails, shrinking images…), use the engine tool (section 11). If there isn't one, don't invent one with tricks: write in the delivery notes what would be needed. ### 8.1 Outbound network (`network`) Every network call of the scripts (`$http.send`, `$filesystem.fileFromURL`, `$filesystem.s3`, and the calls PocketBase makes for the app: S3 storage, backups, OAuth2) goes through the engine, which allows only the addresses declared in `app.json`: ```json "network": { "allow": ["api.openai.com", "*.example.com", "erp.example.local:8443"], "allowHttp": ["old-erp.example.local"] } ``` - An entry is a host name or an IP address, with the port if needed (without it: 443 for https, 80 for http). `*.example.com` covers the names below `example.com` and only public addresses (at least two labels after the `*`: `*.com` is not valid). A wrong entry is ignored and reported by “Upload app”. No `*` alone, no `https://`, no paths. - Only https; plain http only for `allowHttp`. - The engine checks the real IP address after the DNS lookup: a server in the company network is reached only with an exact entry (no `*`); the machine itself (`127.0.0.1`, `::1`) and `169.254.x` only with an entry written as an IP address, never through a name such as `localhost`; the engine itself and the other apps of this server are never reachable from a script, not even with an entry (`403`, code `app_origin`). - The mail server of the app (SMTP: `$app.newMailClient()`, `$mails.*`) is an address too: declare it with its port, for example `"smtp.example.com:587"`. - Redirects only towards declared addresses (at most 9); responses up to 32 MB; at most 300 seconds per call (`timeout`, default 120). - A call that isn't allowed throws an error in English that says what to declare. The engine logs every call (never the content) in the app's “Outbound network” tab; IT approves the addresses at “Upload app” and can change them from that tab. - IT decides the mode of the outbound gate, in the engine's registry (not in `app.json`): *enforce* (Enforce, the starting mode) blocks what isn't declared, *warn* (Warn) only logs it. - Write in `DELIVERY.md` what each address is for. **AI models.** Apps call the AI models (OpenAI, Anthropic, Gemini, Mistral, Azure OpenAI, OpenRouter, Groq, DeepSeek…) **only from the scripts**, with `$http.send` and the provider's address in `network`, **never from the browser**: in a page the key would be visible to anyone. The page calls a route of the app (`routerAdd` with `$apis.requireAuth()`), which calls the model. The key is never in the code, in `app.json` or in the pages: ask IT where it is kept. The engine reads the model and the tokens from the responses (with OpenAI Chat Completions in streaming, ask for `stream_options: { include_usage: true }`) and shows usage and estimated costs on the console's “AI” page; it never records the messages. The complete text, with an example, is in the “Engine tools” chapter (`pyrite.exe tools --md`). **Scripts can't call the engine or other apps.** A script can't use `$http.send` (or `$filesystem.fileFromURL`) to call the engine (console, portal, `/api/platform/…`) or another app on the same server, not even by declaring its address in `network`: the engine recognizes the calls made by apps and answers `403` with `"code": "app_origin"`, and IT sees it in the events. Calling **your own app** (its port or its name) is still allowed: the call counts as a request coming from the network, so without the permissions of someone sitting at the server (called as `http://127.0.0.1:PORT` it is served in clear text even when HTTPS is on; called with the name or the network address of the server, it gets the redirect to https). Calling another program that runs on the same server (for example a local AI model) is still allowed when it is declared in `network` as an IP address with its port: it receives the `X-Pyrite-App` header, which it can ignore. An `X-Pyrite-App` header written by the script is always removed. If two apps need to exchange data, ask IT and write it in `DELIVERY.md`. **Hooks.** Scripts have no `unbind`, `unbindAll` or `bind` on the app's hooks, no `onSend` on the mail client and no `$app.unsafeWithoutHooks()`: the engine's handlers can't be removed or skipped. To add a handler use the usual functions (`onRecordCreate`, `onMailerSend`…) or `bindFunc`. **The limit of the fence.** All this is a defence inside the program: the script runs in the same process as the app's engine, with the same Windows user, so it greatly reduces what a script can do but it is not a full guarantee against a script written on purpose to get out. The full guarantee for apps of third parties (one Windows user for each app) is planned for a future version. Until then, don't count on the fence alone for scripts written by someone IT doesn't trust. ## 9. The pages (`release\pb_public\`) The engine serves the pages on the app's port, from the same address as the APIs: `http://SERVER:8105/` for the pages and `http://SERVER:8105/api/...` for the data. In a PocketBase app, a path with no file in `pb_public` answers with `index.html` (single-page apps work with any path). 1. Always relative paths (`style.css`, `lib/pocketbase.umd.js`, `/api/...`). Never addresses or ports written in the code, and never `http://` written by hand: the same app can be served in http or in https (IT decides, from the engine's Certificates page). 2. **No resources from the Internet**: no CDNs, Google Fonts, remote libraries, external images. Everything needed is in `pb_public` (or in `public` for static apps). Fonts: the system ones (`system-ui, "Segoe UI", sans-serif`). 3. PocketBase SDK: a local copy of `pocketbase.umd.js` in `pb_public\lib\`, the same as the example app (it's inside the downloadable example, with its license). It is created with `const pb = new PocketBase("/");` and finds by itself the session delivered by the engine. 4. The engine inserts by itself, in the HTML pages of apps with login, the single sign-on script (`/__platform/sso.js`) and, in the pages of every PocketBase app, the script of the protection from concurrent changes (`/__platform/concurrency.js`, rule 10): don't add them and don't remove them. If a call answers `401`, reload the page (`location.reload()`): the engine signs in again. This holds for every call, also for the forms' ones (saves, file uploads), not only for the list. 5. Link to go back to the portal: `← Company portal` (in the app's language). No “Sign out” button. 6. The paths starting with `/__platform/`, `/_/`, `/api/platform/` and `/api/tools/` belong to the engine: don't use them for anything else. On the app port `/_/` redirects to the engine console and `/api/collections/_superusers/` is closed (403): the PocketBase dashboard and the superuser sign-in are not available there. `/__platform/info` returns in JSON the app's identifier, its name and the portal address (`{ app, name, portal }`); `/__platform/tools.js` is the helper for the tools from the pages (rule 11). 7. Show the error messages that come from the server (`err.response.message` with the SDK): the app's routes write them in the language of the request (section 10); the messages of PocketBase itself (field validation) are in English. 8. Hiding a button from who doesn't have the role is only a convenience: the real protection are the rules (section 6) and the routes. 9. Simple pages, readable on a small screen: at 390 px wide the page doesn't scroll sideways, and a wide table sits in a container that scrolls by itself (`overflow-x: auto`). Light and dark theme (`color-scheme: light dark` and `prefers-color-scheme`), a label on every field (`