Deliora Manual
Deliora — User Manual
A digital-product storefront: checkout, fulfillment, download links and license keys. Version 4.1.3.
About this manual
Every statement here was written by reading Deliora's own source, and each one carries the file it came from in a bracketed src marker. Paths are relative to the folder this docs directory sits in. If a sentence disagrees with the application, the application is right and this manual has a bug — the markers exist so you can check rather than take our word for it.
Nothing is described that the code does not do. Where something is deliberately limited, that is said plainly rather than left out.
What Deliora is
A storefront that takes an order through a payment provider, fulfills it on the provider's webhook, and issues download links and license keys [src: app/src/Deliora.php:3].
Everything in the domain layer is plain PHP and database work with no reliance on request globals, which is what lets the test suite exercise the real code paths offline [src: app/src/Deliora.php:6].
The sales gate
Deliora can be run with selling switched off, and the gate is a single function every checkout entry point calls first [src: app/controllers/app.php:16].
With sales_live off, checkout routes render a 404 and stop; the storefront shows "Available soon — get notified" in place of those links, so only a stale or direct hit ever reaches the gate — and the demo-mode fake fulfillment is unreachable from the public interface [src: app/controllers/app.php:10].
The webhook endpoint is deliberately not gated [src: app/controllers/app.php:13]. It is authenticated by the provider's signature rather than by the gate, and it has to keep serving refunds and updates for orders that already exist. Switching selling off must not strand a buyer who bought yesterday.
Payments and fulfillment
The provider is chosen in settings, falling back to the value in the configuration file and finally to demo mode; an unrecognized provider is forced back to demo rather than accepted [src: app/src/Deliora.php:26].
Secrets are write-only in the interface and are never echoed or logged [src: app/src/Deliora.php:16]. A settings value overrides the configuration file only when it is non-empty [src: app/src/Deliora.php:17], so clearing a field falls back rather than blanking your live secret.
Download links
A download token is 48 hexadecimal characters, and anything that does not match that shape is rejected before a query is made [src: app/src/Deliora.php:353].
Four things are then checked, and each has its own answer [src: app/src/Deliora.php:345]:
| Result | Meaning |
|---|---|
| 404 | Unknown or malformed token, or the file is missing |
| 410 | Expired, exhausted, or the order was refunded |
| 200 | Served, and the use is claimed |
The order must still be paid [src: app/src/Deliora.php:357], so a refund closes the link.
The file cannot be escaped to
The file lives outside any web-served path, denied by both the server configuration and the router, and is referenced by database id — user input never touches the path [src: app/src/Deliora.php:348].
Even so, the resolved path is checked against the real base directory with a prefix comparison, on top of taking only the base name [src: app/src/Deliora.php:365]. Two independent defences against the same class of attack.
The use is claimed atomically
The counter is incremented by a conditional update that only lands while the count is below the maximum, so two racing requests can never exceed the limit [src: app/src/Deliora.php:371].
If the claim does not land, the download is refused rather than served [src: app/src/Deliora.php:374]. The count is not a display figure; it is the thing that decides.
License keys
A key is verified cryptographically first, then looked up [src: app/src/Deliora.php:400]. The SKU is compared with a constant-time comparison [src: app/src/Deliora.php:403], and a revoked license is refused [src: app/src/Deliora.php:404].
Activation is idempotent per domain
Re-activating a domain that is already activated succeeds without consuming a slot [src: app/src/Deliora.php:418].
A new domain consumes one atomically — the update only lands while activations used is below the limit [src: app/src/Deliora.php:432]. Over the limit, the answer names the reason and reports how many are left [src: app/src/Deliora.php:437].
And there is a guard on the guard. If two requests activate the same new domain at once, one wins the unique index and the other's insert fails — at which point the loser's slot is refunded rather than silently consumed [src: app/src/Deliora.php:443].
That is the failure mode that would otherwise burn a customer's activation for nothing, and it is handled.
Domains are normalized before comparison — scheme, path, port and www stripped, then lower-cased [src: app/src/Deliora.php:383].
The buyer portal
A buyer enters an email address and receives a magic link.
The response is always the same whether or not the address is known [src: app/src/Deliora.php:495]. An invalid address, an unknown customer and a customer with no orders all return exactly what a real one does, so the form cannot be used to discover who has bought from you.
The link is a 48-character token, valid for one hour and single use [src: app/src/Deliora.php:505], and the consumption is race-safe: the update lands only while the token is unused, and a token that loses that race is refused [src: app/src/Deliora.php:523].
Comparison pages, and the honesty invariant
Deliora renders competitor comparison pages, and they carry a rule worth knowing about as a buyer of the product:
A comparison row is only ever rendered when its source URL is non-empty — every visible competitor price carries a live citation [src: app/src/Storefront.php:8].
It is enforced twice: the query will not return a sourceless row, and the view re-checks each row before rendering it [src: app/src/Storefront.php:9].
That is a structural guarantee rather than an editorial policy. A comparison figure with no source cannot reach the page even if somebody puts one in the database.
Users and roles
Deliora is gated by a role check rather than by a permission map: Auth::requireRole('admin') guards the administration side [src: app/controllers/app.php:2112], and the check passes anyone holding the named role — with admin always allowed [src: app/src/Auth.php:42].
Buyers are a separate identity from staff. Auth::requireBuyer() guards the purchase portal and redirects an unauthenticated visitor to the portal sign-in rather than to the staff area [src: app/src/Auth.php:62].
The large public surface is the storefront itself — product pages, comparison pages, the sitemap and the checkout entry points — which is why it is public.
Importing products
Admin → Products → Import CSV takes a CSV (slug, name, optional tagline) in two steps. The dry run runs every check the import runs and writes nothing, so its "ready" count is exactly what the import takes: a slug that already exists, a slug repeated inside the file, and a row without a name are reported as skipped in both steps. Imported products land inactive, without a price.
Backups
The JSON backup is for sharing and for restoring onto a working install: it carries no passwords, secrets, license keys, download, portal or affiliate tokens, cart session keys, or codes of any kind (redeem, upgrade, discount, affiliate), and restoring it keeps the live values of all of those. To rebuild a server from nothing, customers' license keys and codes come back only from the SQLite database file (Admin → Security → Backups → Download database file, SQLite installs) or your own mysqldump (MySQL installs).
What Deliora does not do
It does not process the payment. A provider does; Deliora acts on the provider's signed webhook [src: app/src/Deliora.php:3].
It does not serve a download after a refund [src: app/src/Deliora.php:357], or past an expiry or a use limit [src: app/src/Deliora.php:345].
It does not tell an anonymous visitor whether an email address has bought anything [src: app/src/Deliora.php:495].
It does not render a competitor price without a citation [src: app/src/Storefront.php:8].
It does not stop serving existing customers when you switch selling off [src: app/controllers/app.php:13].
Looking after your install (from 4.0)
Updates from inside Deliora
Updates in the menu opens the Updates page (/admin/updates) [src: app/views/updates.php]. Paste your license key once and press Save key: either key works, the one in your Lemon Squeezy receipt or the one on your order page (it starts with OWX-). Check for updates asks the store for the newest release your license covers; Deliora never checks by itself.
When a new version is ready, Back up and update to that version backs up your data and the current version, installs the new one and checks it. If the update is interrupted, or the new version's database step or that check fails, the previous version is put back by itself. Deliora is unavailable for about a minute while this runs. The update replaces Deliora's files, including any you edited, except .htaccess, .user.ini and robots.txt: an edited copy of those is kept, with the new version's copy beside it as .new to compare and merge. The Health page lists any file that differs from the version installed, so you can see that before you update. A copy of the previous version (data/updates/code-<version>/) and a backup of your data from just before the update stay in data/updates/ [src: app/src/Updater.php]. If Deliora shows an error page after an update anyway, that is your way back: copy the files from code-<version>/ over the app's files, then restore the backup on the backup page. Updating from the page needs PHP's sodium and Phar extensions; if one is missing, the Updates and Health pages say so and updates stay by hand. Where Deliora is hosted for you, the host updates it and the page says so.
Reaching 4.0 from a 3.x version is the last update done by hand: replace the files as before (config.php and data/ stay, and the database migrates itself on the next page load). From 4.0 on, the Updates page does it.
Starting with sample data
The installer offers Start with sample data: four sample products with their orders and licenses to try things on. You can remove it in one click. [src: app/install/index.php:129]. It is ticked by default; untick it to start empty. The sample is added when you open the first page after the installer.
When you are done with it, press the remove button in the sample banner. It asks "Remove the sample products, orders and licenses? Your own records stay." Only the sample goes: your own records stay, and a sample record that one of your own records uses is kept and named in the report [src: app/src/SampleData.php].
Backups by themselves, and a copy somewhere else
On the backup page (/admin/restore, linked from the Security page), Back up once a day by itself is on by default; untick it to stop [src: app/views/restore.php:70]. The first visit at least 24 hours after the last backup starts the next one, after the page has loaded, so no cron job is needed. The newest 14 stay in data/backups/, with credentials left out of them (a restore keeps the live values for those). Back up now takes one straight away.
To keep a copy somewhere else as well, fill in an S3-compatible bucket (Backblaze B2, Wasabi, AWS S3 or your own MinIO): the bucket address (an https endpoint), the region, the bucket name, an optional folder, and the access key ID and secret key. Leave the address empty to keep backups on this server only. The copy goes only to the bucket you set; nothing is sent to Ownware. Once a week Deliora reads the newest backup back through the restore's own dry run and keeps the result for the Health page [src: app/src/BackupTargets.php].
On Nginx: keep data/ private
On Apache, the .htaccess that ships with Deliora keeps data/ (your backups included), src/, config.php and the other private files off the web. Nginx does not read .htaccess, so add these three blocks to your server block, above the block that passes .php files to PHP (Nginx uses the first one that matches):
location ~ ^/(src|data|bin|tests|controllers|deploy|cron)/ { deny all; }
location ~ ^/views/.+\.php$ { deny all; }
location ~ (^|/)(config\.php|\.demo-mode)$|\.sqlite$ { deny all; }
Then open /config.php in a browser: it should answer 403. Only the template files under views/ are closed, not the folder: the app's own saved-view addresses start with /views/ too, and they must reach it.
A complete server block for a new site (PHP-FPM 8.3; change the name, the folder and the socket to yours):
server {
listen 80;
server_name example.com;
root /var/www/deliora/app;
index index.php;
location ~ ^/(src|data|bin|tests|controllers|deploy|cron)/ { deny all; }
location ~ ^/views/.+\.php$ { deny all; }
location ~ (^|/)(config\.php|\.demo-mode)$|\.sqlite$ { deny all; }
location = /install/ { rewrite ^ /install/index.php last; }
location / { try_files $uri /index.php?$query_string; }
location ~ \.php$ {
try_files $uri =404;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
}
The Health page
Health in the menu (/admin/health) shows, one line each, whether the install is looking after itself: the PHP version and extensions, disk space, whether data/ is writable, email, the age of the last backup, the offsite copy, the weekly check, whether an update is waiting, and any file that differs from the version installed. Each line says what to do when it is not fine. The page only reads: it never changes anything and never contacts anything [src: app/src/Health.php].
Connect it to other tools (Zapier, Make, n8n)
Deliora sends a webhook the moment something happens and accepts calls to its API, so Zapier, Make and n8n can connect to it without a plugin. The steps, the events and example recipes are in API.md, in the section "Connect it to Zapier, Make or n8n"; the API page of Deliora on the store shows the same file [src: app/API.md].
New in 4.1.2: sign-in limits
New in 4.1.2 (security update): Two-step codes are now limited (10 wrong per person or per address in 10 minutes), and wrong passwords count per account as well as per address. An installed app whose database does not answer keeps its web installer closed instead of showing the install form. With Stripe, an order is fulfilled only once Stripe reports it paid, and a refund finds its order. /webhook/demo no longer takes posts from outside: while a store was still on the install default ("demo"), an unsigned "paid" post there could fulfil an order. On Ownware Cloud, opening the app from the console starts a fresh session.
Wrong passwords are counted per address and, since 4.1.2, per account too (the email typed, in any case, stored only as a hash). After 10 wrong passwords in 10 minutes the sign-in at /login answers "Too many attempts — wait a few minutes and try again." for that address, and for that account from any address. A right password clears both counts [src: app/src/LoginRate.php:40].
Wrong two-step codes (for people who turned two-step sign-in on under /security) are counted per person and per address. After 10 wrong codes in 10 minutes the code step answers "Too many wrong codes. Wait ten minutes, then sign in again. A recovery code works in place of the code." even for the right code, and the half-finished sign-in is dropped, so the password is asked again [src: app/src/LoginRate.php:91]. The right code clears that person's count; an address's count runs out ten minutes after its wrong codes. Before 4.1.2 the code step had no limit.
If you are locked out: wait ten minutes, then sign in again with your password and a current code, or one of your recovery codes (each works once). If you lost both the authenticator and the recovery codes, whoever runs the server can turn two-step sign-in off for you in Deliora's database (by default the SQLite file in data/, or your MySQL database): UPDATE users SET totp_secret = '' WHERE email = 'you@example.com'; Then sign in with your password and turn it on again under /security.
The demo payment provider takes no posts from outside: /webhook/demo answers 400. Before 4.1.2, while a store still ran on the install default ("demo"), anyone could post an unsigned "paid" event there and receive a license and the download, even with sales off. The simulated checkout and the sample data work as before; they never used that address [src: app/controllers/app.php:3430].
On Ownware Cloud, opening Deliora from the console starts a fresh session [src: app/index.php:56].
The web installer stays closed when the database does not answer: with config.php in place, /install now answers 503 "This app is already installed (config.php exists), but its database is not answering, so the installer stays closed." and writes nothing until the database answers again. Before 4.1.2 a database outage (or a missing SQLite file) showed the install form there, and whoever found it could set up a new administrator. To install again from scratch, remove config.php first [src: app/install/index.php:20].
With Stripe, an order is fulfilled only once it is paid: a checkout Stripe reports as completed but not yet paid (a payment method that settles later, such as a bank debit) now waits for Stripe's "payment succeeded" event, and a failed one delivers nothing; before 4.1.2 the completed checkout alone delivered the license and the download [src: app/src/Payments.php:150]. A Stripe refund now finds its order through the payment it names, so the order is marked refunded and its licenses are revoked; before, Stripe refunds found no order [src: app/src/Deliora.php:158].
New in 4.1.3: security update
New in 4.1.3 (security update): no other site can put Deliora's pages in a frame; the /mcp address refuses requests from other sites' pages; company sign-in is stricter; and the audit CSV treats one more first character as text. Nothing else changed: no feature, screen, setting or database table, and your data, config.php and data/ are not touched.
No other site can frame these pages. Every page now answers Content-Security-Policy: frame-ancestors 'self' and X-Frame-Options: SAMEORIGIN, so a page on another site cannot show Deliora in a hidden frame and trick a signed-in person into pressing a button [src: app/index.php:4]. The installer answers frame-ancestors 'none' and X-Frame-Options: DENY [src: app/install/index.php:4]. If you had Deliora framed inside another site of your own, that frame now stays empty; link to it instead.
/mcp refuses other sites. A request whose Origin header names another site gets 403 before anything is read. A desktop or server client sends no Origin and is served as before, and so is a page at Deliora's own address [src: app/src/Mcp.php:272].
Single sign-on. The sign-in now uses PKCE (S256), so an intercepted sign-in code is of no use on its own; a sign-in that comes back to a different browser session is asked to start again [src: app/src/Oidc.php:228]. With Google as the provider, a company address needs a Google Workspace account of that same domain (the token's hd claim), and Google must report the address as verified, so a personal Google account made with a work address no longer gets in [src: app/src/Oidc.php:215]. Gmail addresses work as before, other providers are unchanged, and password sign-in is not affected.
The audit CSV. A cell that starts with a carriage return now opens in a spreadsheet as text, as cells starting with = + - @ or TAB already did [src: app/src/AuditLog.php:138].
Docker. The Dockerfile now installs PHP's zip extension, so a Docker install can make the backup with files [src: app/Dockerfile:7].
Your store's own pages. A few descriptions, the classic home and its drawing named a product of ours that has nothing to do with your store; they no longer do. The classic home's price range was typed and now reads your own editions.
Also changed, with no effect on a running install: the corner card on a public demo offers only the purchase, and API.md no longer shows the config example of a product we retired.