Warrantora Manual
Warrantora — User Manual
A warranty and RMA register: what you sold, how long it is covered for, what has been claimed, what was decided and why, and where the parcel is. Version 4.1.3 [src: app/controllers/api.php:17].
About this manual
Every statement here was written by reading Warrantora'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.
Nothing is described that the code does not do. Where something is deliberately limited, or currently wrong, that is said plainly rather than left out.
⭐ Expiry is computed, never stored
This is the decision that shapes the product [src: app/src/Warranty.php:5]. A stored expiry date goes stale the instant somebody corrects a sale date or edits a term, and the source is blunt about the consequence: a warranty register whose expiry column disagrees with its own inputs is worse than no register — it is a register that will be quoted at somebody in a dispute [src: app/src/Warranty.php:7].
So the expiry is worked out from the sale date and the term every time it is asked [src: app/src/Warranty.php:85]. Correct the sale date and every downstream figure moves with it.
Where it cannot be known, it says so. An item with no sale date has no expiry and never a guess [src: app/src/Warranty.php:82]:
sold 2026-08-31 -> expiry 2027-02-28
sold (none) -> expiry '' (status: unknown)
⭐ The month-end trap, and three days of free warranty
PHP's own +12 months on 31 January lands on 3 March, because it overflows a short month. A warranty sold on 31 August with a six-month term expires on 28 February, and the customer does not get three extra days because the calendar is awkward [src: app/src/Warranty.php:12].
Executed against PHP's own arithmetic:
2026-08-31 + 6m PHP: 2027-03-03 Warrantora: 2027-02-28
2026-01-31 + 1m PHP: 2026-03-03 Warrantora: 2026-02-28
2024-02-29 + 12m PHP: 2025-03-01 Warrantora: 2025-02-28
2026-11-30 + 3m PHP: 2027-03-02 Warrantora: 2027-02-28
Four dates, four wrong answers avoided. The clamp is tested on every short-month and leap-year edge [src: app/src/Warranty.php:15].
A mistyped sale date is refused rather than rolled forward. The source explains why the obvious check is not enough: createFromFormat does not reject an impossible date — it rolls 2026-02-31 to 3 March and carries on, so a typo would produce a real-looking expiry three days late. checkdate() is what actually refuses it [src: app/src/Warranty.php:39]. Executed:
2026-02-31 -> refused
2026-13-01 -> refused
not-a-date -> refused
2026-02-28 -> 2027-02-28
How the term is decided
The item's own override if it has one, otherwise the warranty's term, otherwise your fallback [src: app/src/Warranty.php:69]. Executed with a 24-month warranty and a 12-month fallback:
override 0 -> 0 months
override 36 -> 36 months
no override -> 24 months
no override, no warranty -> 12 months
An override of zero is a real answer — no cover — and is distinguished from "not set" by null, never by falsiness [src: app/src/Warranty.php:71]. That is the difference between an item you deliberately sold without warranty and one nobody has configured.
What an item's standing means
Five states [src: app/src/Warranty.php:93]. Executed with today at 2026-09-05 and a "soon" window of thirty days:
expiry 2026-09-04 -> expired "Expired" daysLeft −1
expiry 2026-09-30 -> expiring "Expiring soon" daysLeft 25
expiry 2027-06-01 -> active "In warranty" daysLeft 269
expiry 2026-09-30, voided -> void "Voided"
no sale date -> unknown "No sale date" daysLeft null
Voiding wins over everything else — a return, a canceled sale or a mis-keyed serial takes the item out of the register's answers regardless of dates [src: app/src/Warranty.php:101].
One caution when reading the API rather than the screens: daysLeft is a pure count to the expiry date and does not consider voiding [src: app/src/Warranty.php:112], so a voided item still returns a number. Read the status, not the day count.
⭐ A claim is covered if it was covered when it was raised
Not "is it in warranty now". The source states the reason plainly: a claim raised inside the term is inside the term even if it takes three weeks to assess, and letting the clock run out during your own assessment would be a way of rejecting claims by being slow [src: app/src/Warranty.php:194].
Executed on an item sold 2025-09-10 with a twelve-month term, so cover to 2026-09-10:
raised 2026-09-05 -> covered
raised 2026-09-10 -> covered ← the expiry day itself
raised 2026-09-11 -> not covered
The claim machine, and the mandatory reason
Five statuses [src: app/src/Warranty.php:23] and six outcomes [src: app/src/Warranty.php:26]. A claim may only move where the transition table allows, and a reason is required on every step that changes the answer to the customer — assessed, approved and rejected [src: app/src/Warranty.php:153].
Executed, with the refusal each attempt actually produces:
raised -> approved (no reason) A claim cannot go from Raised to Approved.
raised -> assessed (no reason) Write what you found, and why. A decision with no reason
on it is the one that cannot be defended later.
raised -> assessed "cracked casing" ALLOWED
resolved -> raised A resolved claim stays resolved — raise a new claim if
the fault came back.
approved -> rejected "changed mind" A claim cannot go from Approved to Rejected.
raised -> raised That claim is already Raised.
Two things worth drawing out. A claim cannot jump from raised to approved — something has to be assessed first. And an approved claim cannot be flipped to rejected; the way back is a resolution, not a quiet reversal.
The RMA side is a separate machine with its own order — issued, on its way back, received, closed [src: app/src/Warranty.php:29].
⭐ The customer's own warranty page (1.1)
Off until you switch it on in Settings → Your customers [src: app/controllers/v11.php:171]; then link to /warranty from your website.
- Serial and email together, or nothing. A customer types the serial number and the email address on the registration; both have to match a live item, ignoring case and outer spaces [src: app/src/Portal.php:47]. A guessed serial with the wrong address shows nothing, and an item registered without an email address cannot be looked up (the page says to get in touch).
- What they see: the product, whether it is covered and until when, their claims and how each stands; for a decided claim, the outcome and your written reason [src: app/src/Portal.php:63]. Never the internal assessment, the supplier or another customer.
- Raising a claim [src: app/controllers/v11.php:61]: it lands in Claims as raised, marked as raised by the customer through the page, with its in-warranty answer frozen on the day it arrives like every claim, and with the photo or document they add (checked by its own bytes). The assessment and the decision stay with you.
- Guards: a hidden trap field, at most 20 look-ups and 3 claims an hour from one address (stored as a salted fingerprint) [src: app/src/Portal.php:36], and one open claim per item from the page [src: app/src/Portal.php:57].
- Each claim has its own link for the customer (
/warranty/claim/<token>), which the emails carry [src: app/controllers/v11.php:127].
Customer emails (1.1)
Through your own SMTP server, behind a master switch and a switch per message, all off by default [src: app/src/CustomerMail.php:24]:
| message | when | what it says |
|---|---|---|
| claim received | a claim is raised, by you or from the page | the claim reference and its link |
| claim decided | a claim is approved or rejected | the decision, the outcome and your written reason, once per decision |
| cover ending | a set number of days before the cover ends (default 30) | the end date, once per item per end date [src: app/src/CustomerMail.php:200] |
A message carries only what is the customer's own [src: app/src/CustomerMail.php:62]. A mail server that refuses a message never blocks the claim or the decision; the failure is in the outbox under Settings. On a demo install nothing is sent: the message is written to the outbox and no connection is opened. The cover-ending notes go a few at a time when the dashboard opens, and a daily cron can call /cron/customer-mail?t=<scheduled-backup token> [src: app/controllers/v11.php:241].
Claim updates by text, on your own SMS account
Off until you connect an SMS gateway you already pay for, in Settings → Claim updates by text [src: app/controllers/v11.php:195]. Warrantora sells no credits and adds nothing to the price: your gateway bills you, at its own rate, for every text it sends.
- Any HTTPS gateway. Warrantora sends one HTTPS POST per text, as JSON or form-encoded, with your three field names for the number, the sender and the message (
to,from,textby default; Twilio's areTo,From,Body), and your key as a Bearer token, as HTTP Basic (user:password, for TwilioAccountSid:AuthToken) or not at all. Any 2xx answer counts as sent [src: app/src/Sms.php:80]. - Your business name first. Every text starts with the business name at the top of Settings, so texts stay off until the business is named [src: app/src/Sms.php:48].
- Only customers who ask. With texts on, the claim form on your warranty page shows "Text me when this claim moves on" (unticked) and a mobile number field. A customer who ticks it and gives a number that can be one is texted; a number typed without the tick is not kept [src: app/controllers/v11.php:81]. The tick is checked every time a text is about to go, inside the query that picks the recipient [src: app/src/Sms.php:226]. Claims your team raises, or that come in through the API, are never texted: nobody ticked anything.
- Three texts at most per claim: when the claim arrives; when you approve or reject it (the decision, and for an approval the outcome, with the reason on the claim's page); and when you close it. Each one starts with your business name and links to the claim's own page [src: app/src/Sms.php:185]. A decision made on the claim page, through the API or by an agent through the MCP tool texts the same way [src: app/controllers/app.php:580, app/controllers/api.php:159, app/controllers/v3.php:287].
- How a customer stops them. The claim's page shows Stop text updates; one press stops the texts about that claim and forgets the number [src: app/controllers/v11.php:139]. Opening the link only shows the page (a link preview cannot stop anything); the button does it. When a customer asks by phone or email instead, Stop texts (the customer asked us) on the claim does the same [src: app/controllers/v11.php:154].
- Never the same text twice. Each text is written to the outbox under its own key before it is sent, so a second attempt to send it sends nothing [src: app/src/Sms.php:235].
- Sending hours. "Claim received" goes at once: the customer has just sent the claim. The decision and the close wait for your sending hours, 09:00 to 20:00 by default, counted in the business time zone (daylight saving included) [src: app/src/Sms.php:231]. Outside them they wait in the outbox and go when the dashboard next opens inside the hours, or from
/cron/customer-mail?t=<scheduled-backup token>[src: app/controllers/v11.php:249]. If you use texts, call that address every hour, so a decision made in the evening goes out the next morning even when nobody opens the dashboard. A customer who stops texts while one waits does not get it [src: app/src/Sms.php:280], and switching texts off cancels the ones still waiting [src: app/controllers/v11.php:217]. - A daily limit. At most 100 texts a business day by default (Settings; every attempt counts, sent or refused); past it, texts wait for the next day, "claim received" included [src: app/src/Sms.php:172]. The warranty page takes any number a visitor types, so the limit is what bounds your bill.
- When the gateway says no. The claim is raised or decided anyway. Whoever acted sees what the gateway answered, and the outbox in Settings and the claim page show it. A refused text is tried again on a later day, inside the sending hours, while the customer still wants texts: never twice on one day, and at most three attempts in all, within a week. A run stops at the first refusal [src: app/src/Sms.php:273].
- Test it. Send test sends one text through the saved gateway and shows the gateway's answer as it came; switch texts on and save first [src: app/controllers/v11.php:229].
- On the record. Every attempt is in the audit trail with the last four digits of the number only [src: app/src/Sms.php:261]. The key is write-only in Settings and is left out of the JSON backup and the audit trail [src: app/controllers/v3.php:73]. On a demo install no text leaves: it is written to the outbox and the gateway is never called.
⭐ Supplier claim-back (1.1)
Name the supplier or maker on a registration, and every claim that was approved in warranty with a repair, a replacement or a refund waits under that supplier on Supplier claim-back [src: app/src/Recovery.php:26]. Goodwill, "no fault found", rejected and out-of-warranty claims never go there.
- A pack gathers one supplier's claims (an administrator starts it) [src: app/src/Recovery.php:39]. It downloads as a CSV and as a PDF carrying each claim's product, serial, batch, sale date, fault, assessment, outcome, RMA and the written reason, and the evidence list.
- The supplier's answer is recorded: sent, accepted, part accepted or rejected, with their reference (a credit-note or case number) and a note; the sent and answered dates are stamped [src: app/src/Recovery.php:70]. The page counts, per supplier, how many packs they accepted and rejected.
- No amounts, ever. A pack records references and outcomes; Warrantora still holds no money.
- A claim sits in one pack at most. A pack that has not gone to the supplier can be taken apart, and its claims wait again [src: app/src/Recovery.php:84].
Faults by product and batch (1.1)
Faults by product lists, for each product (or product and batch, or product and model), the units registered, the claims raised in the period, the claims per 100 units, and how those claims were decided [src: app/src/Insights.php:28]. A batch or model is marked stands out when it has at least three claims and at least twice its product's own rate: two claims on a small batch are noise, a third at double the rate is a conversation with the supplier, and the claim-back pack takes it from there. CSV at /export/faults.csv, JSON at GET /api/reports/faults.
The evidence archive (1.1)
The JSON backup carries every row, including which evidence file belongs to which claim, but not the files. Backup & restore → Download the evidence archive writes every claim photo and PDF into one ZIP with a manifest: each file's claim, original name, type, size and SHA-256 [src: app/src/EvidenceArchive.php:32]. Files are streamed from disk and stored uncompressed, so the host needs no zip extension. Restoring the archive writes a file only when an evidence row in this database names it, the file is missing from disk, the bytes are an accepted type and they match the manifest's checksum; nothing is overwritten [src: app/src/EvidenceArchive.php:114]. After a full restore: the JSON backup first, then the archive.
Registration at the point of sale (1.1)
A till, a shop or your own code sends each sale to POST /api/sales with a key whose holder may register items [src: app/src/Sales.php:40]. Every unit becomes a registered item: one per serial number on the line, otherwise one per quantity. The term is the line's own, else the first rule in Settings → Register at the point of sale whose text is in the product or model, else the default term there [src: app/src/Sales.php:20]. Each unit carries a reference made from the order, the line and the unit, and that reference is unique, so a sale sent twice registers nothing twice; a term that does not exist refuses that unit rather than registering it on the wrong term [src: app/src/Sales.php:76]. Each new item fires item.registered, as on every door.
Your own reports (1.2)
Reports (in the menu) builds a report without a spreadsheet.
- Pick what to report on: the register of items, or the claims.
- Pick the columns from a fixed list, and the filters: status, outcome, product, a date range (sold or raised), in or out of warranty.
- Sort by any column, either way [src: app/src/Reports.php:48].
- An item's status and days left are worked out as they are everywhere else in Warrantora, never stored [src: app/src/Reports.php:72].
- The report shows on screen (the first 500 rows) and downloads in full as CSV. A value that starts like a formula is kept as text.
- Save it under a name of up to 80 characters, used once [src: app/src/Reports.php:137]. Saved reports are listed for everyone with the register, and open or download in one click.
- Members and administrators save and remove reports; a viewer reads them [src: app/controllers/v11.php:497].
The items import's dry run runs the import's own checks (1.2) [src: app/controllers/api.php:409]. It counts exactly the rows the import brings in, and names a serial twice in the file.
Users and roles
Three roles [src: app/controllers/v3.php:20]. A viewer reads items; a member can also register items, work claims and attach evidence [src: app/controllers/v3.php:21].
Backups are administrator-only
Both backup downloads need the settings permission, which only an administrator holds — the JSON export [src: app/controllers/api.php:322] and the SQLite file [src: app/controllers/api.php:333]. Before 1.0.4 they were gated by sign-in alone, so a viewer could download the whole register.
Webhook events
Settings offers the events Warrantora sends: item.registered, claim.raised, claim.assessed, claim.approved, claim.rejected and claim.resolved [src: app/src/Webhook.php:23]. Each fires from the browser, the REST API and the agent tools alike:
| event | browser | REST | agent |
|---|---|---|---|
item.registered | [src: app/controllers/app.php:353] | [src: app/controllers/api.php:70] | [src: app/controllers/v3.php:219] |
claim.raised | [src: app/controllers/app.php:518] | [src: app/controllers/api.php:119] | [src: app/controllers/v3.php:248] |
claim.<status> | [src: app/controllers/app.php:573] | [src: app/controllers/api.php:152] | [src: app/controllers/v3.php:286] |
Before 1.0.4 Settings offered asset.created and asset.disposed — another product's events, never sent — so a receiver added from Settings heard nothing, and the agent tools fired nothing. A receiver saved that way is switched to every event on upgrade.
⚠️ The agent probe disagrees with the product about its own name and version
| surface | says |
|---|---|
| REST ping and OpenAPI | warrantora, version 1.0.4 [src: app/controllers/api.php:17] |
| the MCP specification | warrantora, version 1.0.4 [src: app/controllers/v3.php:96] |
| the unauthenticated MCP probe | Warrantora, version 3.1.1 [src: app/controllers/v3.php:314] |
The probe is the first thing an agent client sees, and it disagrees with the specification served on the very next request — on both the name's capitalization and the version number. Match on the lower-case name and take the version from the specification. This has been raised against the product.
The API and agent access
Seven tools [src: app/controllers/v3.php:105], and they are annotated honestly: four read — the item list, an expiring report, the claim list and one claim in detail — carry readOnly true [src: app/controllers/v3.php:114], and the three that write carry it false [src: app/controllers/v3.php:196]. A client that trusts the annotation will not call a writing tool by accident.
The instructions lead with the same fact this manual does [src: app/controllers/v3.php:99]:
"An item is something you sold; its EXPIRY IS COMPUTED from the sale date plus the term and is never stored, so it is always right."
Data requests: export or erase a customer (4.1.1)
An administrator opens Data requests (in the menu beside the audit trail). A customer is found by the email on their registrations, or by their name where a registration has no email:
- Download their data writes one JSON file with every item registered to them, its claims, the evidence and RMA records on those claims, and every email and text sent to them.
- Erase this customer replaces their name, email and reference on every item registered to them with
[removed]; on the claims for those items it clears the mobile number for texts (the texts stop), takes their name out of "Customer (…) via the warranty page", deletes the photo they sent from disk and blanks its file name; and it blanks every email and text sent to them [src: app/controllers/v3.php:895].
The items and their claims stay: the product, serial, sale date and term, the fault, your assessment, the decision and the outcome are the warranty record. Photos your team took on the bench stay with the claim. Erasure is refused while a claim on their items is still open, because you still owe them a decision or a repair [src: app/controllers/v3.php:948]. The audit trail records that an erase happened and how many rows it touched, never the customer.
Kept: the fault as the customer described it in their own words, with the claim, because it is part of the warranty record.
What Warrantora does not do
It does not show a customer anything without their serial and their email together [src: app/src/Portal.php:47].
It does not text a customer who did not ask, and it sells no text credits: texts go through your own gateway, only to a customer who ticked the box on the claim form [src: app/src/Sms.php:226].
It does not put an amount on a supplier claim-back [src: app/src/Recovery.php:16].
It does not store an expiry date [src: app/src/Warranty.php:5].
It does not guess an expiry when there is no sale date [src: app/src/Warranty.php:82].
It does not let a month-end overflow hand out extra cover [src: app/src/Warranty.php:12].
It does not accept an impossible date [src: app/src/Warranty.php:39].
It does not record a decision without a reason [src: app/src/Warranty.php:153].
It does not let your own assessment time expire a claim [src: app/src/Warranty.php:194].
Looking after your install (from 4.0)
Updates from inside Warrantora
Updates in the menu opens the Updates page (/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; Warrantora 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. Warrantora is unavailable for about a minute while this runs. The update replaces Warrantora'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 Warrantora 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 Warrantora 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: a small manufacturer's registered products and warranty claims to try things on. You can remove it in one click. [src: app/install/index.php:128]. 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, customers and claims? 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 & restore page (/restore), Back up once a day by itself is on by default; untick it to stop [src: app/views/restore.php:100]. 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 backups stay in data/backups/ (14 unless you change Backups to keep), 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 Warrantora 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 Warrantora 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)/ { 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/warrantora/app;
index index.php;
location ~ ^/(src|data|bin|tests|controllers|deploy)/ { 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 (/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)
Warrantora 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 Warrantora 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. Accepting an invite starts a fresh session. Single sign-on starts a fresh session. 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 Warrantora'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.
Accepting an invite now starts a fresh session (a new session id), as every other sign-in already did [src: app/controllers/v3.php:787].
Single sign-on now starts a fresh session too [src: app/controllers/v3.php:879].
On Ownware Cloud, opening Warrantora from the console starts a fresh session [src: app/index.php:55].
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:28].
New in 4.1.3: security update
New in 4.1.3 (security update): no other site can put Warrantora'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 Warrantora 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:12]. If you had Warrantora 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 Warrantora'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].
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.