Visitora Manual
Visitora — User Manual
A self-hosted visitor sign-in book: a kiosk at the door, a badge, a live list of who is inside the building, and an evacuation roll-call. Version 4.1.3 [src: app/src/App.php:8].
About this manual
Every statement here was written by reading Visitora'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.
⭐ One rule decides who is in the building
On site is derived, never stored. A visit is on site when it has a sign-in time and no sign-out time. There is no is_onsite column to go stale, no nightly job to forget, and no way for the evacuation list to disagree with the visit log [src: app/src/Visit.php:7].
The dashboard count, the CSV, the badge check and the API all read that same one rule [src: app/src/Visit.php:9]. It is written once, as a SQL fragment every reader and writer shares [src: app/src/Visit.php:127], and the source explains why the table alias is a parameter rather than hard-coded: the moment one caller hand-rolls the clause to fit its own FROM, there are two definitions of who is in the building. The test suite greps for a second copy [src: app/src/Visit.php:129].
⭐ Signing out cannot be broken by a double-tap
signOut() is a conditional update — WHERE signed_out_at IS NULL — so two taps, two devices or a double-submitting kiosk produce exactly one sign-out at one time [src: app/src/Visit.php:12]. It also reports whether it was the call that did it, so callers never announce an event that did not happen [src: app/src/Visit.php:15].
⭐ Two rows for one body is the error that leaves somebody inside
Signing in somebody who is already on site is refused outright, with the reason: "That person is already signed in. Sign them out first." [src: app/src/Visit.php:214]. The source says why in the line above: two rows for one body is exactly the error that gets somebody left in a building, so the door refuses rather than tidying up later [src: app/src/Visit.php:209].
⭐ What a visitor agreed to is self-contained
When a visitor accepts the site rules, the exact text and its SHA-256 are stored on the evidence row — not a pointer to a settings field somebody can edit afterward [src: app/src/Visit.php:17].
Two consequences, both stated: changing the rules tomorrow cannot rewrite what somebody accepted today, and proving what they accepted needs no join [src: app/src/Visit.php:19].
The contractors' document (1.2)
Contractors often have more to agree to than visitors: an induction, a permit-to-work summary, where the isolation switches are. Under Settings, write a contractors' document with its own heading [src: app/src/Visit.php:302]. Somebody who signs in at the kiosk as a contractor then sees it below their details and ticks that they have read and accepted it. A contractor who does not tick it is not signed in, and a visitor never sees it [src: app/src/Visit.php:200].
The accepted text is stored on the visit exactly as the site rules are: the words and their fingerprint, so editing the document later cannot change what a contractor accepted. It is kept for as long as you keep visit records. The visit page shows each text that was accepted, with its "accepted, intact" check.
Entrances (1.1)
A site with more than one way in lists its entrances in Settings, one per line: "Main reception", "Goods yard" [src: app/src/Visit.php:245]. Put a kiosk at each and open it once at its own address, /kiosk?entrance=Goods yard. The tablet keeps that in a cookie, shows the entrance under the welcome line, and every sign-in there records the door it came in by [src: app/controllers/app.php:536]. Only a name on the list is recorded; a kiosk opened with any other name records none. /kiosk?entrance= with nothing after it clears it.
The entrance shows on the visit, in the visit log, on the roll call ("came in by Goods yard"), in both CSV exports, and in the API and webhooks as entrance. Visitora does not count kiosks.
Keeping staff names off the kiosk (1.1)
A kiosk that lists every host shows your staff's names to anyone standing at it. Switch on Keep staff names off the kiosk in Settings and the kiosk lists nobody: visitors type the name of the person they are here to see, and couriers type who a delivery is for. A full name finds the host, and so does a first or last name that only one host has. Case and spacing do not matter. A name two hosts share finds nobody [src: app/src/Visit.php:275].
When a host is required, a name that finds nobody is refused with "We could not find that name here", which names no one. When a host is optional, the visit goes ahead and the typed name is kept with the reason for the visit ("Interview (asked for Pat Nobody)"), so reception can find the person. A host number posted to the kiosk is ignored in this mode [src: app/controllers/app.php:564]. The kiosk's thank-you screen, which anyone can open by its address, then says "your host" rather than a name [src: app/views/kiosk_done.php:37].
Deliveries (1.1)
Switch on Deliveries at the kiosk in Settings and the kiosk offers "Delivery for someone here?". The courier chooses who it is for — or types a name if they are not on the list — and what it is: a parcel, a letter, food, or something that needs a signature [src: app/controllers/v11.php:393]. The recipient is told by email when email is on, in team chat channels (delivery.received), and on their own phone page; the courier's screen says "has been told" only when the email went, "posted to the team" when only chat took it, otherwise "reception will let them know". Reception's Deliveries page lists what is waiting — with how each recipient was told — and the menu shows the count; "Collected" records when and, if you like, to whom [src: app/controllers/v11.php:433]. A delivery is not a visit: couriers are not put on the roll call (one who stays on site signs in as a visitor or contractor). Collected deliveries follow the retention window; one still waiting is kept.
The watchlist (1.1)
Watchlist (in the menu, for whoever manages hosts) holds people reception wants to know about when they sign in: a full name, an optional email, and the reason. A sign-in matches when the name has the same words in any order — case, accents and punctuation ignored — or the email is the same; a shared first name alone is not a match [src: app/controllers/v11.php:305]. The visitor sees exactly what anyone sees. Reception is told [src: app/controllers/v11.php:316]: the visit is marked (the visit list shows "watchlist", the visit page shows the reason), the desk's email address gets a message with the reason when email is on, team chat channels get one line with the name and "see the note in Visitora" — never the reason — and visit.watchlist fires. The audit trail records the act, not the name or the reason.
The watchlist is personal data you hold. A subject-access export includes the entry about that person; removing an entry clears its mark from their visits. Keep reasons factual and remove people when it no longer applies.
The badge at the kiosk, and signing out with its QR (1.1)
Settings → The badge at the kiosk: off (reception hands out badges, as before), show the badge with a Print button, or print it as soon as someone signs in. The badge is a web page sized for 86 × 54 mm card, printed by whatever printer the kiosk's browser uses — Visitora ships no printer driver and prefers no vendor. It prints once, only on the kiosk that signed the person in; reopening the thank-you address shows no badge.
The badge carries a QR: a sign-out link for that visit only, stored as a hash and made when the badge is printed, so printing again replaces it [src: app/controllers/v11.php:233]. The visitor scans it with their phone as they leave and taps "Sign me out" [src: app/controllers/v11.php:255]; or a barcode scanner reads it into the kiosk's sign-out box. Both sign out through the same rule as every other door, send visit.signed_out, and are audited with where they came from. The QR leaves the shareable backup with the other credentials.
Badges are reused, and what happens when they run out
A badge is physical: it comes back and goes out again. The allocator hands out the lowest number in the configured range that nobody on site is holding, so a desk with fifty badges does not run out on visitor fifty-one and two people on site never wear the same number [src: app/src/Visit.php:22].
Worth knowing exactly what "does not run out" means. The allocator walks the range and returns the first free number, or zero when every badge in the range is on somebody [src: app/src/Visit.php:110]. A sign-in with no free badge still records the visit — the badge row is only created when a number was actually issued [src: app/src/Visit.php:228] — and the printed on-site list shows an em-dash in the badge column for that person [src: app/views/onsite_print.php:48].
So the guarantee is reuse, not an infinite supply: nobody is turned away, and nobody is given a number somebody else is wearing, but a fifty-first simultaneous visitor is on the list without a badge.
The evacuation roll-call
Two states — accounted and missing [src: app/src/Visit.php:34] — and a tally that counts everyone on site, everyone marked accounted, and the difference [src: app/src/Visit.php:418]. Executed on four people, one accounted, one marked missing, one unmarked, one accounted:
4 on site 2 accounted 2 NOT accounted
An unmarked person counts as not accounted, which is the only safe direction for a fire drill: silence is not a headcount.
Staff on the roll call (1.1)
Switch on Staff on the roll call in Settings. On the Hosts page, give each person a kiosk code (generated, shown once) or their own phone link (shown once; a new one replaces it). At the kiosk, Staff: tap in or out with your code signs them in if they are out and out if they are in [src: app/src/Presence.php:110]; on their phone, the link's page does the same with one button. Wrong codes are limited to ten in ten minutes per screen [src: app/controllers/v11.php:25].
Staff are inside by the same one rule as visitors — signed in and not signed out [src: app/src/Presence.php:88]. The evacuation page shows Staff, Contractors and Visitors in that order, with one tally, and a member of staff is marked accounted for exactly like a visitor [src: app/src/Presence.php:135]. A new drill clears their marks too. Presence rows follow the retention window. Presence only: no hours, no timesheets.
Wardens' phones (1.1)
On the roll-call page, Wardens' phones makes a personal link for each fire warden (whoever manages hosts can do it; the link is shown once, with a QR, and stored only as a hash). The warden opens it on their phone — no account — and sees the same roll call from the same one query, without the staff's menu or tools [src: app/controllers/v11.php:153]. Their ticks go through the same rule as the reception desk's, so several wardens can work the list at once and the tally is shared; each tick is recorded under the warden's name [src: app/controllers/v11.php:165]. The page keeps its last copy when the network drops (the service worker keeps each warden's list as it keeps the staff page's), and "Add to Home Screen" opens the warden's own roll call. Revoke a link and it answers "not found".
Telling the host (1.1)
Email (Settings, off until switched on twice), a line in a Slack, Teams, Google Chat or Discord channel (API & webhooks, format) [src: app/src/Webhook.php:32], and, for a host who asks for it, a text (next section). The kiosk tells the visitor their host has been told only when the email or the text went [src: app/controllers/app.php:581]; when only a chat channel took the line, it says the arrival was posted to the team.
A text to the host, on your own SMS account
Texts are off until you connect an SMS gateway you already pay for, in Settings → Text messages [src: app/controllers/texts.php:30]. Visitora sells no credits and adds nothing to the price: your SMS provider bills you for every text, at its own rate [src: app/src/Sms.php:3]. On the public demo this block is read-only [src: app/controllers/texts.php:34] and no text is ever sent [src: app/src/Sms.php:103].
- Any HTTPS gateway. Visitora 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 Twilio,AccountSid:AuthToken) or not at all [src: app/src/Sms.php:74]. Any 2xx answer counts as sent. Send test sends one text and shows exactly what your gateway answered [src: app/controllers/texts.php:58]. The key is write-only [src: app/controllers/texts.php:51] and is left out of the shareable JSON backup. - Only hosts who ask for it. A host turns arrival texts on themselves, from their own phone link: they type their mobile number and tick Text me at this number when a visitor of mine signs in; the moment is recorded [src: app/controllers/texts.php:16]. An administrator sees who has them on (Hosts → Arrival texts) but cannot turn them on for anyone, and a number on a host's record is never texted because it is there: the host's own opt-in is a condition of the query that finds who to text [src: app/src/Sms.php:225]. When staff presence is off, Hosts offers a Phone link button so each host can get their link; the link then does only this.
- What the text says. Your business name, the visitor and their company, the badge, and a stop link, never the purpose of the visit, the same as the email [src: app/src/Sms.php:217], for example
Fern Studio: your visitor Vera Visitor (Kestrel Ltd) has signed in at reception, badge 7. Stop these texts: https://…/s/…. - Stopping. Every text ends with a stop link. Opening it only asks; pressing Stop texts puts the number on the stop list and turns that host's arrival texts off [src: app/src/Sms.php:182]. A host can also turn them off on their phone link. Turning them on again with their own number is a new request and lifts their own earlier stop [src: app/src/Sms.php:301].
- Once, and a daily cap. Each arrival is texted at most once: it is claimed under its own key before your gateway is called [src: app/src/Sms.php:263]. At most 100 texts go out a day by default (Settings → Text messages, Most texts a day) [src: app/src/Sms.php:270]: the kiosk is public, so a run of made-up sign-ins can never become a run of paid texts.
- When it fails. The gateway's answer is written next to that text under Latest texts in Settings, and the sign-in carries on as normal.
- A confirmation for a visitor, if you offer it. Tick Offer visitors a confirmation by text, and a visitor confirming a pre-registration sees an unticked box and a number field. Ticking it sends one text [src: app/src/Sms.php:238], for example
Fern Studio: thank you, Pia. You are expected on Sun 4 Oct to see Tara Text. Please sign in at reception when you arrive. Stop texts: …[src: app/controllers/app.php:667]. A number that pressed stop gets none. - What is kept. The text log holds a masked number for display and a SHA-256 of the number for the stop list. The stop link's token is kept only as its hash [src: app/src/Sms.php:271], and a host's texting number is left out of the shareable backup [src: app/controllers/v3.php:52]. Log rows, and a number a visitor typed on a pre-registration, follow your retention window [src: app/src/Sms.php:314].
Retention, and a purge that does not keep a list of who it forgot
Which finished visits fall outside the retention window is computed from a cutoff date [src: app/src/Visit.php:482]. Executed:
today 2026-03-01 keep 30 days -> cutoff 2026-01-30
today 2026-01-01 keep 365 days -> cutoff 2025-01-01
today 2026-03-01 keep 1 day -> cutoff 2026-02-28
Three properties are worth stating plainly:
Open visits are never in scope. Somebody still inside the building cannot be forgotten, whatever the calendar says [src: app/src/Visit.php:479].
The purge returns a count, never a name — because "a purge log that lists who it forgot has not forgotten them" [src: app/src/Visit.php:498]. Visitors left with no visits go too [src: app/src/Visit.php:499]. It does not reach the mail log, which keeps the invitation and arrival emails, or the names on pre-registrations (4.1.1).
It works on both database engines. The date extraction is spelled by the driver rather than hard-coded, because hard-coding either dialect makes the purge a silent no-op on the other — and "a retention job that silently deletes nothing is the worst kind of green" [src: app/src/Visit.php:482].
How long somebody has been in
Durations are formatted for a person rather than a log [src: app/src/Visit.php:574]. Executed across the boundaries:
59s -> 59s 60s -> 1m 3600s -> 1h 3660s -> 1h 1m
86399s -> 23h 59m 86400s -> 1d 200000s -> 2d 7h
An open visit is measured to now; a closed one to its sign-out [src: app/src/Visit.php:566].
Users and roles
Three roles [src: app/controllers/v3.php:70]. Signing in and out are separate permissions from viewing, and both belong to member and above [src: app/controllers/v3.php:68].
Note that a viewer is granted audit.view explicitly, listed by name in the role rather than picked up by a wildcard [src: app/controllers/v3.php:71]. In this product that reads as deliberate: a visitor book is a safety record, and who signed whom out is part of it. If you expected read-only accounts not to see the audit trail, change the role — but the grant here is stated, not accidental.
Backups are gated on settings.edit [src: app/controllers/api.php:231], which no role below admin holds [src: app/controllers/api.php:236]. The JSON backup — the copy you can hand to someone — leaves out every password, key and other credential, visitors' phone numbers and IP addresses, and each pre-registration's link [src: app/controllers/v3.php:42]; the outbox log keeps an invitation without its link [src: app/controllers/app.php:174]. The SQLite file is the whole database, unredacted.
Webhooks
Two events are offered for subscription [src: app/src/Webhook.php:18], and both are sent.
visit.signed_in goes when somebody signs in at the kiosk [src: app/controllers/app.php:566].
visit.signed_out goes however the visitor left: the kiosk's own sign-out, the staff page, the bulk end-of-day sweep, the REST route and the agent tool all send it through one helper [src: app/controllers/app.php:206]. If reception signs the building out at six o'clock, your integration hears about every visitor.
The API and agent access
Six tools [src: app/controllers/v3.php:132]: five read — who is on site, the visit list, one visit, hosts, and who is expected today — and exactly one write, signing a visitor out [src: app/controllers/v3.php:220].
The instruction explaining that asymmetry is the sharpest sentence in the product [src: app/controllers/v3.php:129]:
"Nothing in this tool set can sign a visitor IN: putting a name on a muster list without a person standing at the desk is exactly the error that gets somebody left in a burning building."
The agent is also told that on-site status is derived on every read and that signing out is idempotent, so calling it twice signs one person out once [src: app/controllers/v3.php:127].
What Visitora does not do
It does not store an on-site flag [src: app/src/Visit.php:7], so the evacuation list cannot drift from the visit log.
It does not let an agent sign anybody in [src: app/controllers/v3.php:129].
It does not sign the same person in twice [src: app/src/Visit.php:214].
It does not let an edited set of site rules rewrite what somebody already accepted [src: app/src/Visit.php:17].
It does not purge anybody who is still inside the building [src: app/src/Visit.php:479], and it does not keep a list of the people it purged [src: app/src/Visit.php:498].
Subject access and erasure: what an erase removes and what it keeps (4.1.1)
A visitor's page carries Erase this person for an admin, refused while they are signed in. Erasing clears their name, company, email and phone from every visit, the address each visit was signed in from, the pre-registration their host made for them, and the invitation and arrival emails in the mail log. The dates and durations stay, so your figures do not change.
Kept: the purpose written on each visit, and an entry about them on the barred-person watchlist, because the visit record is the site's own and the watchlist is a safety list you keep on purpose.
Looking after your install (from 4.0)
Updates from inside Visitora
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; Visitora 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. Visitora is unavailable for about a minute while this runs. The update replaces Visitora'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 Visitora 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 Visitora 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 design agency's visitors, hosts and a fortnight of visits to try things on. You can remove it in one click. [src: app/install/index.php:111]. 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 visitors, hosts and visits? 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:78]. 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 Visitora 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 Visitora 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/visitora/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)
Visitora 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 Visitora 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. A matched staff code no longer resets the kiosk's wrong-code count. 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 Visitora'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.
Staff codes at the kiosk are counted per screen address: after 10 wrong codes in 10 minutes it answers "Too many wrong codes from this screen — wait ten minutes, or ask reception." Since 4.1.2 a matched code no longer resets that count; the count runs out ten minutes after the wrong codes [src: app/controllers/v11.php:36].
On Ownware Cloud, opening Visitora from the console starts a fresh session [src: app/index.php:53].
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 Visitora'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 Visitora 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 Visitora 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 Visitora'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.