A sweep of the URL map found 165 GET routes that answered anybody, with no token at all: the whole asset inventory, every machine, PC, printer and network device, the USB checkout history and who is holding which drive, the staff directory with its photographs, every core report. Guest access had been argued about one feature-flag at a time while the front door stood open. Each of those now carries the read permission it belongs to. That also makes the guest role mean something: a site can open exactly the reads it chooses, and nothing follows by accident. Thirty-three endpoints stay public, and each is named with its reason rather than left to be inferred: the app shell and its bootstrap, the settings allowlist (the handler already filters an anonymous caller down to branding and a few keys), images fetched by <img src> which carries no Authorization header, what a display kiosk renders before anybody signs in, the parts and USB kiosks, the client scripts that run as SYSTEM before a user session exists, and a secure-send link whose unguessable token IS the authorisation. tests/test_core/test_public_read_surface.py is the standing gate, the companion to the mutating-route sweep that has guarded writes for months. A new public read fails CI until somebody adds it to that list in front of a reviewer. Four further reads need a login but no permission, because the handler decides from who you are - your own user record, your own API tokens, the role catalogue the grid renders - and the same test proves each still refuses an anonymous caller. Verified not vacuous by ungating one route and watching the sweep name it. The employees plugin gained employees.view; the directory is names, SSOs, photographs and reporting lines, and it had no permission of any kind. Around ninety tests asserted payload shapes against endpoints that answered anybody. They are about what the answer says, not about who may ask, so they are signed in now rather than the gate being loosened. The ones that assert an anonymous caller is refused were left exactly as they were.
Employees plugin - directory database contract
Read-only lookups against a separate employee/HR directory database. Powers:
- Employee search (add people to a notification, browse the directory)
- Single + batch SSO lookup
- Recognition notifications (name + photo on the shopfloor / lobby displays)
The plugin never writes to this database. Use a read-only account.
This is the integration most likely to differ per site. HR / directory systems vary widely, so expect to map a site's schema to the contract below - the
CREATE VIEWrecipe at the end is the normal way to do it.
Connection
Credentials resolve settings-first, then environment, except the password which is env-only (never stored in the app database).
| Field | Setting key (editable in the setup wizard) | Env var (fallback) |
|---|---|---|
| Host | employee_db_host |
EMPLOYEE_DB_HOST |
| Database | employee_db_name |
EMPLOYEE_DB_NAME |
| User | employee_db_user |
EMPLOYEE_DB_USER |
| Password | (not stored) | EMPLOYEE_DB_PASSWORD |
Set the password in .env; the setup wizard's Features step shows the exact
line to paste. Engine is MySQL/MariaDB (pymysql).
Required schema
The plugin runs these queries verbatim, so a site's database must expose a
table (or view - see below) named employees with these columns:
| Column | Type | Notes |
|---|---|---|
SSO |
INT | Unique person id. Lookups require it to be numeric |
First_Name |
VARCHAR | Displayed as the given name |
Last_Name |
VARCHAR | Displayed as the surname; sort key |
Team |
VARCHAR | Team / group label |
Role |
VARCHAR | Job title / role |
Picture |
VARCHAR | Image filename (see Photos) |
Queries actually executed:
-- search
SELECT SSO, First_Name, Last_Name, Team, Role, Picture
FROM employees
WHERE First_Name LIKE %s OR Last_Name LIKE %s OR CAST(SSO AS CHAR) LIKE %s
ORDER BY Last_Name, First_Name LIMIT %s;
-- single / batch
SELECT SSO, First_Name, Last_Name, Team, Role, Picture FROM employees WHERE SSO = %s;
SELECT SSO, First_Name, Last_Name, Team, Role, Picture FROM employees WHERE SSO IN (...);
Rows are returned to the API with these exact column names. The frontend
(EmployeeSearch, NotificationForm, EmployeeDetail, ShopfloorDashboard) reads
SSO, First_Name, Last_Name, Team, Role, Picture as-is - do not
rename them in the response.
Photos
Picture holds an image filename (e.g. 123456.jpg), not a path or blob.
The app renders it as /static/employees/<Picture>, so the image files must
live in the app's static/employees/ directory. Leave Picture empty/NULL for
people with no photo; the UI falls back to initials.
Two ways to provide the directory
Option A - map an existing HR/directory database (see the view recipe below). Use this when the site already has a system of record for people.
Option B - self-hosted directory (managed in-app) for sites with no HR
database. Set employee_directory_mode to selfhosted and manage people under
Settings > Employee Directory (add / edit / delete + CSV import) - no SQL
needed. The app owns a directoryemployees table (migration 7d16); the lookup
APIs read it automatically in this mode.
If you would rather load it with SQL, the canonical table is equivalent to:
CREATE DATABASE shopdb_directory CHARACTER SET utf8mb4;
USE shopdb_directory;
CREATE TABLE employees (
SSO INT NOT NULL PRIMARY KEY,
First_Name VARCHAR(100) NOT NULL,
Last_Name VARCHAR(100) NOT NULL,
Team VARCHAR(100) NULL,
Role VARCHAR(100) NULL,
Picture VARCHAR(255) NULL
);
-- add people (or bulk-load from CSV with LOAD DATA INFILE)
INSERT INTO employees (SSO, First_Name, Last_Name, Team, Role, Picture)
VALUES (123456, 'Jane', 'Doe', 'Inspection', 'Quality Tech', '123456.jpg');
Put photo files (named as in Picture) under the app's static/employees/.
In self-hosted mode the directoryemployees table lives in the main app DB, so
no separate database is required.
Adapting a different site schema (recommended: a view)
Sites whose HR/directory database uses different table or column names should
not be forced to rename anything. Instead, create a read-only view
named employees that maps local columns to the names above:
CREATE VIEW employees AS
SELECT
person_id AS SSO,
given_name AS First_Name,
surname AS Last_Name,
department AS Team,
job_title AS Role,
photo_filename AS Picture
FROM hr_people
WHERE active = 1;
Grant the app's read-only user SELECT on the view. No app code changes -
point employee_db_* at that database and the plugin works.
Notes:
SSOmust be numeric (single/batch lookup validateisdigit()).- Column-name case follows your database's identifier casing; match the names above exactly on case-sensitive platforms.
- If the directory is unreachable or the
employeesobject is missing, lookups return a 500 and the rest of the app keeps working (the feature degrades, it does not crash the app).