Home›Lendra›Manual
Lendra · Manual

Lendra Manual

Lendra — User Manual

A lending desk for physical assets, run on your own server. Version 4.1.3 [src: app/controllers/api.php:18].

About this manual

Every statement here was written by reading Lendra'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 Lendra is

Assets go out to people and come back. The product is one small state machine over an asset's status and whether its loan is still open [src: app/src/Checkout.php:5], and the source calls that engine the moat, because correctness there is the whole value [src: app/src/Checkout.php:3].

available ──check-out──▶ checked_out ──check-in──▶ available
    │  ▲                                              │  ▲
    │  └──────────────── set-status ─────────────────┘  │
    └──────────────▶ maintenance / retired ─────────────┘   (not checkoutable)

A loan is open while its return timestamp is null [src: app/src/Checkout.php:5].

The four guarantees

The source states them as guarantees and covers each with tests [src: app/src/Checkout.php:13].

Only an available asset can go out. Checking out something already lent, in maintenance, or retired is refused with a reason that says which [src: app/src/Checkout.php:14], and the refusal names the actual state rather than a generic failure [src: app/src/Checkout.php:220].

Check-out is atomic. The flip from available to checked out is a conditional update inside a transaction, so two racing requests can never both hand out the same asset [src: app/src/Checkout.php:16].

Check-in closes the loan — recording the return time and the condition it came back in — and returns the asset to available. Checking the same loan in twice is refused [src: app/src/Checkout.php:19].

Overdue means an open loan whose due time has passed [src: app/src/Checkout.php:22]. Nothing is stored as "overdue"; it is a comparison.

How the atomic guard actually works

Only a row that is still available flips. If the update matches no rows, another request or a status change got there first, and the answer is a refusal rather than a second loan [src: app/src/Checkout.php:249].

That is worth knowing when two people work the same desk: "that asset was just checked out by someone else" is the system being right, not the system failing.

The check-out desk, and scanning

The desk screen is built around a scan. It opens with an autofocused field labeled "Scan or type an asset tag" [src: app/views/checkout.php:31] and the page itself says "scan a tag to begin" [src: app/views/checkout.php:8].

Behind it sits a lookup that matches an asset tag exactly first — because, as the source puts it, a scanner types the tag and nothing else — and only then falls back to a name or category search [src: app/controllers/v3.php:1335]. The match ignores letter case on both SQLite and MySQL [src: app/controllers/v3.php:1364] (before 3.1.4 it used a SQLite-only collation, and every scan failed on a MySQL install).

That is what makes an ordinary handheld barcode scanner work here without any driver or integration: the common kind emulates a keyboard, so it simply types the tag into that field and the exact match fires. Partial text works the same way for the times you are typing by hand [src: app/controllers/v3.php:1366].

The desk shows you the verdict before you commit, and the screen is explicit that the verdict is the real one: it refuses exactly what the check-out itself would refuse [src: app/views/checkout.php:36].

The lookup requires a signed-in user with permission to view assets [src: app/controllers/v3.php:1355].

The camera (since 3.2.0). Use camera beside the field opens the phone's, tablet's or laptop's camera; hold a label's barcode across the middle of the picture and the tag lands in the field exactly as a keyboard scanner would put it there, so the same verdict follows [src: app/views/checkout.php:32], [src: app/assets/desk.js:73]. Where the browser has a built-in barcode reader (Chrome and Edge on Android, ChromeOS, macOS) it is used; everywhere else, Safari on iPhone and iPad included, Lendra's own Code 128 reader runs in the page [src: app/assets/scan.js:173], [src: app/assets/scan.js:136]. Nothing leaves the device: no upload, no cloud decode. Browsers open the camera only on an https address (or on the machine itself), so on a plain-http install the button says so and the typed field carries on. There is still no hardware driver: a scanner that emulates a keyboard needs none.

Labels

Assets → Print labels (since 3.2.0) prints a sheet of labels for the assets you tick: an A4 sheet of 24 (70 × 37 mm), a US Letter sheet of 30 (2⅝ × 1 in), or a 50 × 30 mm label-printer roll, with a "labels already used" count for part-used sheets [src: app/controllers/labels.php:14]. Each label carries the asset's name, its tag as a Code 128 barcode with the tag printed beneath, and a QR code of this install's own address for that asset, so a phone's ordinary camera app opens the asset's page — signed in, you land on it; signed out, on the login [src: app/controllers/labels.php:73]. Where a label is too narrow for both, it leaves the QR code off rather than print a barcode too fine to scan (at least 0.25 mm a bar) [src: app/views/labels_sheet.php:8]. Print label on an asset's page prints that one label on the roll size. Print at actual size, not "fit to page".

Due dates

A due date is a local calendar date, converted to the end of that day in your configured timezone [src: app/src/Checkout.php:59]. Something due today is not overdue until that day is over.

A loan cannot start overdue. A due date in the past is refused, and the reasoning is specific: a back-dated loan silently poisons the overdue report, which is the most-looked-at screen in the product [src: app/src/Checkout.php:228]. Due today is fine [src: app/src/Checkout.php:233].

The check compares against the same clock the rest of the operation uses rather than the wall clock, so the rule holds on any timeline [src: app/src/Checkout.php:230].

Returns, and damaged items

A return records the condition the item came back in. Every grade puts it back on the shelf except damaged, which sends it to maintenance [src: app/src/Checkout.php:305]: a broken item is not issued to the next person and not offered to the reservation queue. When it is mended, set it back to available and the head of the queue is offered it, exactly as a return would [src: app/src/Checkout.php:336].

Signatures at hand-over and return (3.2.0)

The desk can ask the borrower to sign on the screen when an item goes out and when it comes back. Settings → Signatures at the desk: off, optional (the default: the pad is there and a loan without a signature is fine) or required (no hand-over or return without one) [src: app/controllers/signatures.php:16]. The signature is checked before anything is written: a pad nobody signed, or anything that is not the pad's own PNG, is refused [src: app/src/Signature.php:39]. It is kept with the loan, shown on the asset's loan history and on the loan's record page, which prints from the browser [src: app/controllers/signatures.php:44]; the receipt PDF says the loan was signed and when. With signatures required, the desk's one-click Return sends you to the check-in page instead, because it has no pad [src: app/controllers/v3.php:1396]. Loans made over the REST API or by your own agent over MCP carry no signature.

Reminders to borrowers

Two emails can go to a borrower, both through your own SMTP server and both off until you switch them on under Settings → Email → Write to the borrower:

  • Due back soon — one message per borrower listing every item of theirs falling due within the number of days you choose (1 by default) [src: app/controllers/v3.php:1178]. Each loan is reminded once per due date; the slot is claimed before the mail server is contacted, and released if the send fails. Items already overdue are left to the overdue notice.
  • Overdue — one message per borrower listing everything they hold late, and not again until the cadence you set has passed.

Lendra runs no background job, so something has to run them: cron/overdue.php on a schedule sends both (--dry-run names who would be written to), and so does the token-guarded /overdue/notify-cron [src: app/controllers/v3.php:1286]. The Email these borrowers button on the Overdue page sends the overdue notices only. The script answers only on the command line.

Reservations — the waiting list

An asset already out can have a queue [src: app/src/Reservations.php:3], and the rule is stated in the source in one paragraph "so it can be argued with" [src: app/src/Reservations.php:5].

Anyone may join. The queue is strictly first-come, first-served by reservation id, with no priorities [src: app/src/Reservations.php:6]. The reasoning is a good one: a lending desk that can be jumped is a lending desk people stop trusting [src: app/src/Reservations.php:7].

When the asset comes back, the person at the head is offered it — their reservation becomes claimable and a claim window opens, 48 hours by default [src: app/src/Reservations.php:9]. During that window the asset can be issued only to them.

If they do not collect it in time, the reservation expires and the offer passes on [src: app/src/Reservations.php:11], so the queue never stalls on somebody who has stopped caring [src: app/src/Reservations.php:12]. Once the queue empties, the asset is simply available again.

Why an offer rather than an automatic loan

Because issuing an asset to somebody who is not standing there creates a loan nobody can return [src: app/src/Reservations.php:16]. The reservation reserves; a human still checks it out [src: app/src/Reservations.php:16].

Reservations do not weaken the double-issue guard

This is the design point worth understanding. The queue is asked before the atomic flip and never in place of it — it adds a condition on issuing rather than a second route around the guard [src: app/src/Checkout.php:239].

The source says the same thing from the other side: the atomic update stays exactly as it was, and reservations only add the question "is this asset promised to somebody else right now?" [src: app/src/Reservations.php:19].

When the borrower being issued to is the one holding the claim, that reservation is settled inside the same transaction as the loan, so the two can never disagree [src: app/src/Checkout.php:266].

Two refusals in the queue

A second place in line is not a stronger claim — joining twice is refused [src: app/src/Reservations.php:72].

A retired asset cannot be reserved, because it is not coming back [src: app/src/Reservations.php:78].

An inactive borrower can neither borrow [src: app/src/Checkout.php:216] nor join a queue [src: app/src/Reservations.php:82].

The borrower's own page (3.2.0)

On a borrower's page the desk can create a link for them (shown once) or email a new link straight to them without anyone at the desk seeing it; only its hash is stored, and a new link replaces the old one [src: app/controllers/selfservice.php:40]. The link opens a page, with no sign-in, that shows what they have out and when it is due, their place in each queue, and the items you have marked Borrowers can request it on the asset form [src: app/controllers/selfservice.php:80].

A request is a place in that item's queue, under exactly the rules above: first come, first served, and an item on the shelf is offered to them at once [src: app/controllers/selfservice.php:116]. A human still hands it over at the desk. A borrower holds at most five open requests at a time (Settings can change it), can cancel only their own, and an inactive borrower's link does not open [src: app/controllers/selfservice.php:15]. The page never shows who else has an item.

Your own fields on every asset (3.2.0)

Settings → Asset fields adds fields to every asset: text, number, date, a choice from your own list, or yes/no — serial number, purchase date, warranty end, the team that owns it [src: app/src/AssetFields.php:128]. They appear on the asset form and page. Each value is checked against its type before the asset is saved, so a date is a date and a choice is one of its options; a bad value refuses the save and says why [src: app/src/AssetFields.php:44]. Search finds an asset by any of its field values, Assets → Export CSV (/assets.csv) has a column per field [src: app/controllers/fields.php:42], and the assets import matches a column to a field by the field's name [src: app/controllers/api.php:448]. The REST API and your own agent over MCP get the values by field name. Removing a field removes its values; changing a field's type keeps the values already filled in.

The import's dry run applies the import's own checks (3.3.0): the screen before you confirm counts a row as ready only if the import would take it, so an impossible date, a value outside a choice, a tag already in the register or a tag repeated inside the file is listed as skipped, with the reason, before anything is written [src: app/controllers/api.php:396]. Before 3.3.0 the dry run checked only that the name column had a value, so such a row was counted ready and then skipped at import.

Checks due: PAT tests, calibrations, services

A date field can be a due date: in Settings → Asset fields, give it a number of days to remind ahead, and tick not lent once passed if an item past it must not go out [src: app/src/AssetFields.php:93]. Checks due (linked from the dashboard, which shows a notice while any are due) lists every such date inside its window or passed [src: app/src/AssetFields.php:109]. An item past a "not lent" date is refused by the check-out itself, with the field and the date named, and the desk's verdict says so before anything is written [src: app/src/Checkout.php:236], [src: app/controllers/v3.php:1325]. Recording the new date on the asset lets it go out again; a date that only reminds never stops a loan.

Signing in with your directory (3.2.0)

Settings → Directory sign-in lets your team sign in with the name and password they already use on Active Directory or any LDAP server. It needs PHP's LDAP extension on the server; the settings card says when it is missing. The login form tries the local password first, so an administrator's own login and 2FA keep working when the directory is down, then the directory [src: app/controllers/app.php:20].

How the directory is asked: an empty password is refused before anything is sent, because many directories treat it as an anonymous sign-in [src: app/src/Ldap.php:54]; the person is found with your user filter, (uid={username}) or for Active Directory (sAMAccountName={username}), with the name escaped as a value so a star or a bracket cannot widen the search [src: app/src/Ldap.php:31]; then Lendra signs in to the directory as that entry with the password typed, and the directory says yes or no. Lendra never stores the passwords people sign in with.

A person is matched to their Lendra account by email the first time, then by their directory entry; an account linked to one entry never signs in as another [src: app/controllers/ldap.php:17]. With Create an account on a first sign-in on, a newcomer gets an account with the default role. Groups are optional: Only members of turns everyone else away; when an admin or member group is set, membership decides the role at every sign-in, and the last administrator is never demoted. Check with the directory in Settings tries a name and password without signing anyone in [src: app/controllers/ldap.php:76].

The usage report (3.3.0)

Usage report shows how much the pool was used between two dates, grouped by item, category, department or borrower, for one category or all [src: app/controllers/usage.php:29]. For each row: loans in the period, loans begun in it, days out, late (returned after the due date, or still out past it), and the items or borrowers involved. A loan counts only for the part of it inside the period, and one still out runs to now, so a month's figures are that month's time out whenever the loans began. By item or category it also shows the share of the period the items were out; a category is measured against its items that are not retired. Periods run up to a year, in your timezone. Download CSV gives the same table, each cell through the spreadsheet-formula guard. Anyone who can see loans can read it [src: app/controllers/usage.php:95].

Users and roles

Three roles [src: app/src/Perms.php:21]. The source names the line it draws, and it is not "who can type":

who can make an asset or a person disappear — issuing and returning is the daily job of whoever is at the desk; retiring an asset, editing a borrower's details, anonymising them, or restoring the database are things you want a name attached to and fewer hands on [src: app/src/Perms.php:5].

Permissionviewermemberadmin
dashboard.viewyesyesyes
assets.viewyesyesyes
borrowers.viewyesyesyes
loans.viewyesyesyes
overdue.viewyesyesyes
loans.issue / loans.return—yesyes
assets.create / assets.update / assets.photo—yesyes
borrowers.create / borrowers.update—yesyes
reservations.manage—yesyes
everything else——yes

A viewer sees the catalog, who has what, and the overdue list, and nothing else [src: app/src/Perms.php:10]. A member is the desk [src: app/src/Perms.php:11]. An admin adds retiring assets, borrower deletion and anonymization, settings, the team and restore [src: app/src/Perms.php:12].

Grants below admin are enumerated rather than wildcarded, deliberately: a tidy *.view quietly hands a viewer the audit trail and the team roster, and that should be a decision rather than a pattern match [src: app/src/Perms.php:14].

Backups are admin-only

Both database downloads, /backup.json [src: app/controllers/api.php:297] and /backup.sqlite [src: app/controllers/api.php:325], require settings.manage, which only an admin holds, and the Security page shows the buttons to admins only. A backup contains every table, with only credential columns redacted [src: app/controllers/api.php:306] — the borrower list, the team roster and the audit trail — so it sits with the same role as restore. Before 3.1.4 both downloads checked sign-in alone, and a viewer login could take the whole database.

Back to an earlier day (3.3.0)

The scheduled backup keeps the last 14 files on the server [src: app/controllers/v3.php:778], and Backup & restore lists them with a Dry run button on each [src: app/views/restore.php:65]. It opens the same dry run as an uploaded file, straight from the server, so going back to last Tuesday needs no download and no upload; nothing is written until you confirm. Only a name from that list is accepted, never a path [src: app/controllers/v3.php:693], and only an admin can use it. A stored backup, like a downloaded one, has its credential columns redacted, and a redacted cell keeps the value you have now.

The API and agent access

Five tools are exposed to an agent, of which two write [src: app/controllers/v3.php:24]: listing assets, asset detail and the overdue report on the read side; issuing and returning on the write side.

An API key carries its holder's role, and a key belonging to a viewer is refused the write tools with an explanation naming the role [src: app/src/Perms.php:58].

The instructions given to an agent are worth quoting, because they teach it something a naive client would get wrong: one asset can have at most one open loan at a time, the server enforces that atomically, and an issue can legitimately fail with "already checked out" even though a list fetched a moment ago said otherwise [src: app/controllers/v3.php:20].

Dates sent in are local calendar dates; times returned are UTC [src: app/controllers/v3.php:23].

What Lendra does not do

It does not take payment, deposits or fines. It records what went out, to whom, when it is due and what condition it came back in.

It does not issue an asset to somebody who is not there [src: app/src/Reservations.php:16]. A reservation becomes an offer with a deadline; a person still checks it out.

It does not prioritize a queue [src: app/src/Reservations.php:6]. If your desk needs somebody to jump the line, cancel the reservation ahead of them — deliberately, with the cancellation on the record — rather than expecting the software to arrange it.

It does not follow nested directory groups. Group membership is read from the group's own member list [src: app/src/Ldap.php:93]; a person in a group inside the group is not counted.

It does not print the signature on the receipt PDF. The receipt says the loan was signed and when; the loan's record page shows the signature itself.

It does not decide that a loan is late for you. Overdue is a comparison between a due time and now [src: app/src/Checkout.php:22]; what you do about it is yours.

Data requests: what an erase removes and what it keeps (4.1.1)

A borrower's page carries Data request for an admin: download their data as JSON, or erase their details. Erasing replaces their name, email, phone and department, the signatures they drew at hand-over and return, and the notes on their reservations. Their loan history stays: the assets still need their own record. It is refused while they still hold an item.

Kept: the audit trail's line that this borrower was added, which carries their name, because the audit trail cannot be edited.

Looking after your install (from 4.0)

Updates from inside Lendra

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; Lendra 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. Lendra is unavailable for about a minute while this runs. The update replaces Lendra'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 Lendra 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 Lendra 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 shared equipment pool with borrowers and loans to try things on. You can remove it in one click. [src: app/install/index.php:123]. 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 equipment, borrowers and loans? 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:91]. 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 Lendra 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 Lendra 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/lendra/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 (/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)

Lendra 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 Lendra 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. 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 Lendra'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.

On Ownware Cloud, opening Lendra from the console starts a fresh session [src: app/index.php:60].

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].

New in 4.1.3: security update

New in 4.1.3 (security update): no other site can put Lendra'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 Lendra 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 Lendra 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 Lendra'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.

← Back to Lendra · Quickstart · API · Test run