MethodOperations
partial some parts checked against the code, others not yet · what the statuses mean
Operations & security (preliminary)
Preliminary — not a final design. This page describes today’s runtime (a local tool its users sign in to) and an honest, current security posture. It is not a hardened production spec. The go-live items are tracked in the maintenance backlog; treat everything here as a snapshot that will change if the app is ever hosted.
In simple terms
The app runs on your own machine: a Python backend and a browser frontend
talking over localhost. There is no server to deploy, no cloud and no secrets in
configuration. People sign in with a local password; each project belongs to a
workspace, and only its members can see it. By default only this Mac can open
it. Started with serve.py --share (since 2026-11-04), colleagues on the office Wi-Fi
open it too — over HTTPS, on the Wi-Fi address only, with a certificate this Mac
makes itself; the database, the measured series and the certificate authority’s key
stay on this Mac. The app can also run as one Docker image — on a server behind Caddy
(HTTPS from Let’s Encrypt) or on a user’s own computer; the image is written and its first
build is pending (Run with Docker).
How it runs today
- Backend: the launcher
backend/scripts/serve.py --reload(FastAPI + SQLite + Parquet on127.0.0.1:8000; plainuvicorn … --reload-dir backendruns the same — the Host check is inside the app since 2026-11-08, so it holds either way;--shareadds the shared HTTPS address). See Getting started. - Frontend:
npm run dev(Vite, port 5173 exactly — it stops rather than move to another port, because the API accepts pages from its configured origins only), callingVITE_API_BASE(defaulthttp://localhost:8000); when shared, the backend serves the built app and the API on one port (the API under/api). - State: a single SQLite file + a
data/tree underbackend/data/— both gitignored, so this folder is the only copy. Back them up together: the default books a workspace made or edited are Excel files inbackend/data/dab_workspaces/(the database only names them), so a DB-only backup loses their values. - The activity log is a second database file,
audit.db, beside the app’s database (PTX_AUDIT_DB_PATHmoves it), with its-walside file while the server runs. Back it up together with the database, the same way (the SQLite backup API, never a plain copy of a file in use): a backup without it loses the record of who did what. The app’s own backup before a workspace is deleted copies it beside the database copy. - A container image — written, first build pending.
Dockerfile,docker/(the entrypoint, the health check, the compose file with Caddy) anddocker/image.workflow.yml(enabled by copying it under.github/workflows/), which builds, smoke-tests and pushes the image only on a release tag or when started by hand. The server inside runs asserve.py --behind-proxy; everything it keeps is under/data. See Run with Docker. No orchestration; the Mac install runs by hand as above.
Security posture (honest)
This is the current state, not a recommendation for hosting.
- Sign-in and roles (since 2026-11-03). A local password (salted scrypt, at
least 10 characters; a sign-in throttle after five failures) opens a session held in
an HttpOnly, SameSite=Lax cookie that lasts 14 days of inactivity. Every route but
health and the three sign-in routes needs one. Roles per workspace: a viewer
reads, exports and explores (prefeasibility solves are never stored); an editor
also edits, runs solves and analyses, takes snapshots and creates projects; an
admin also deletes projects and edits the workspace’s default assumption books. An id from
another workspace answers 404. Users are never deleted, only disabled. A forgotten
password is reset on the machine:
backend/scripts/set_password.py <e-mail>. - The sign-in throttle survives a restart (since 2026-11-08). Failures are counted per
e-mail and per client address — and per client for the one-time link routes — in the
database (
login_failures), not in memory: restarting the server no longer gives a guesser a fresh budget. The first four failures are free, then 2 s, 4, 8, 16, 32 and at most 60 s between attempts; a key quiet for 15 minutes is forgotten; the table keeps the most recent keys only (bounded), and holds SHA-256 hashes, never an e-mail or an address. Concurrent failures are each counted (one write lock). The machine-side password reset forgets that e-mail’s failures. - A default assumption book per workspace (since 2026-11-08). Each workspace’s admins make, edit, activate and delete only their workspace’s books; no other workspace sees them (another workspace’s version name answers 404). The book shipped with the app is read-only for everyone — nobody can change, import into or delete it — and is the seed of every workspace that has no active book of its own. The app writes a default book only in the acting workspace’s own folder (copying a shipped file there on its first change), so no workspace can change or remove a file another reads.
- Disabling an account from the screen (since 2026-11-08). In Workspace settings an admin can switch a member’s account off — signed out at once on every device, sign-in refused with the usual one sentence — and back on. Only for a person who belongs to no other workspace: an admin of one workspace must not cut someone’s access to a workspace they do not run, so for a person in two the page says why and offers “remove from this workspace” instead (the page learns only whether the person is in another workspace, never which). Never the owner, never yourself, never the last enabled admin; every rule is checked inside one guarded database write. Rows the person made keep their author.
- The super admin — no silent access (decided on 2026-11-08, in the app since 2026-11-09). The account that set
the app up is its super admin; another is named or removed only on the machine
(
backend/scripts/super_admin.py --add | --remove | --list) — never through the web, and only an account that can already sign in (one waiting on an invitation link could be claimed through that link). The super admin’s All workspaces page shows every workspace’s name, owner, member and project counts, dates — never a project, book or result. To open a workspace’s projects the super admin joins it with a role they choose; its members see it in their members list (“Added by the super admin …”) and in “Since your last visit”, and leaving, renaming the workspace or handing it on is shown the same way (each act is written with its record in one transaction). Anyone else asking a super-admin address gets 404 “Not Found” — the answer an unknown address gets — so they learn nothing, not even whether a workspace exists. The flag is read on every request: removed on the machine, it is gone at the next page. A workspace admin cannot switch the super admin’s account off. - The activity log — who did what, when. Every change that succeeds, every export and every sign-in and sign-out is written as one sentence with its time, the person, the workspace and, for a change, the values before and after (Activity log). It never stores what a request carried — no request bodies, passwords, tokens or addresses — nor any reading other than exports, nor failed sign-ins (the throttle counts those). A workspace’s admins read their workspace’s log; the super admin reads every workspace’s, but an entry of a workspace they have not joined shows only when, where, who and what kind. Entries are kept in full for 12 months, then only as counts per month.
- Deleting a workspace (since 2026-11-09). Its owner deletes it from Workspace settings,
the super admin any workspace. The dialog offers every project as a bundle first (the
super admin only as a member), lists the members who would be left with no workspace
(they can no longer sign in), and asks for the workspace’s name — checked again on
the server. Before anything is removed the server writes a backup of the whole
database with the SQLite backup API into
~/Desktop/PtX_backups(PTX_BACKUP_DIR;ptx_projects-<stamp>-before-delete-workspace-<id>.db, mode 0600, checked to hold the workspace’s projects), and copies the workspace’s own default books and the activity log (…-audit.db) beside it; no backup, no delete. The deleted workspace’s entries stay in the activity log until they are folded into monthly counts; nobody is a member any more, so the super admin sees only when, who and what kind of change. It cannot be undone from the app — only by restoring that backup with the app stopped. Stored solve frames and saved runs are files, not in the backup: the offered export carries them. Measured series are never removed. - Members and one-time links (since 2026-11-04). An admin adds a colleague by e-mail in Workspace settings: someone who already has an account joins at once; anyone else gets a one-time link (32 random bytes; only its SHA-256 is stored; valid 7 days; works once; withdrawable). The app sends no e-mail — the admin hands the link over privately, and whoever opens it chooses the password, so treat it like a password. A link never replaces a password someone already chose. Removing a member signs them out of that workspace at once; their account and their work stay. The owner of a workspace cannot be demoted or removed, and no change leaves a workspace without an admin. Changing your own password signs out your other sessions. The link routes are throttled per client, and the server’s access log never shows a link’s token. Viewers see no write controls (cosmetic — the server refuses anyway).
- Accepted for a single company’s install (revisit before unrelated companies share one): the answer to “add a member” tells an admin whether that e-mail has an account here (added at once vs a link); an admin holds every link they make and could open it themselves, choosing the password of an account named by someone else’s e-mail — a workspace that later adds that e-mail adds whoever holds it. There is no e-mail verification; admins are trusted colleagues.
- Cross-site requests: a state-changing request must carry the app’s
X-PtX-Requestheader from an allowed origin — on the localhost socket exactly the configured dev frontend pages (PTX_DEV_ORIGINS, defaulthttp://localhost:5173andhttp://127.0.0.1:5173; since 2026-11-08 — before, any localhost port was accepted, so a page of another local tool could act as the signed-in user); on the shared socket exactly this Mac’s sharing addresses, and the request must name its page (Origin). CORS reflects the same list, never*. The variable accepts loopbackhttporigins only; anything else in it is ignored with a warning in the server’s log. - The Host check is part of the app (since 2026-11-08): every request whose
Hostis notlocalhost/127.0.0.1(plain HTTP) — or, over HTTPS, exactly this Mac’s sharing names while it shares — answers 400 before any route runs. A web page that points its own domain at this Mac (DNS rebinding) reaches nothing, whether the server was started by the launcher or by a plainuvicorn backend.api.app:app. - Sharing on the office Wi-Fi (since 2026-11-04,
serve.py --share):- Where it listens: the Wi-Fi interface’s private IPv4 only (10.x, 172.16–31.x,
192.168.x) — never every interface (
0.0.0.0), never a VPN or tunnel;127.0.0.1keeps serving this Mac. It refuses to start before the first account exists, whilePTX_DEV_PRINCIPALis set, or on a Wi-Fi without a password. When the Mac’s address or network changes, it closes the shared address by itself. - HTTPS: a certificate authority made on this Mac by the system
openssl(key mode 0600 in the gitignored private folder, refused by the pre-commit hook); it may vouch only for.localnames and private addresses (X.509 name constraints), so even a stolen key could not impersonate a public site. The server certificate names the Mac’s.localname, its Wi-Fi address,localhostand127.0.0.1, and is re-issued when the address changes. TLS 1.2 or newer, forward-secret ciphers. - One origin: the built app and the API (under
/api) on one port — no CORS on the shared address at all; the role matrix and the per-workspace 404 rule are the same as on localhost (a test walks every route under the prefix). - Cookie: over HTTPS
__Host-ptx_sessionwithSecure(the browser refuses it unless set over HTTPS for this host, so no device on the network can plant one); on localhost the plainptx_sessionas before. The dev switch is ignored over HTTPS and first-run set-up is refused there. - Each address answers only to its own names (
Hostcheck, in the app since 2026-11-08): a web page that points its own domain at this Mac reaches nothing.
- Where it listens: the Wi-Fi interface’s private IPv4 only (10.x, 172.16–31.x,
192.168.x) — never every interface (
- Behind a proxy — the container (
serve.py --behind-proxy; written, first build pending). The server listens on every address of the CONTAINER only with that flag and a public name (PTX_PUBLIC_HOST), and answers to that one name (400 for any other). It believesX-Forwarded-Proto/X-Forwarded-Forfrom the proxy’s addresses only (PTX_TRUSTED_PROXIES;*and every address refused), so the scheme sets the Secure__Host-cookie and the sign-in throttle counts the real client, not the proxy. Every request that did not come through the proxy over HTTPS is refused (403), the health route excepted; without a proxy onlylocalhostis served (a user’s own computer, the port published on its loopback). A change must name its page and that page must be the app’s own address; no CORS; the dev switch is refused at start and never honoured; the first account is made on the machine’s command line, never through the page behind a proxy (backend/scripts/set_up_account.py). The image runs as a non-root user; files it writes are readable by that user only; in the compose file the app has no port of its own and its network has no route out — only Caddy, which addsPermissions-Policy,Cross-Origin-Opener-PolicyandCross-Origin-Resource-Policyand drops itsServerheader. The image holds committed files only (git archive), and.dockerignorerefuses the database, the private folder, imported series, stored results,.envfiles and keys a second time. - Security headers on every answer (both addresses):
nosniff,Referrer-Policy: no-referrer(a join link’s token never leaves in a Referer), a Content-Security-Policy (the app’s own scripts, styles, fonts and connections only — no outside origin at all since 2026-11-08;frame-ancestors 'none'),X-Frame-Options: DENY, and over HTTPSStrict-Transport-Security(one day). A server error answers one sentence, never a traceback; no answer names the server software. - No request leaves this Mac for a font (since 2026-11-08). The data font, JetBrains Mono
(SIL Open Font License 1.1, which allows bundling), ships with the app in
frontend/public/fonts/jetbrains-mono/beside its licence, authors and source (SOURCE.txt, with each file’s SHA-256); the interface uses the system font. Before, every colleague’s browser fetched the fonts from Google Fonts (and Fontshare, for a display face nothing used) — telling those services when and from where the app was opened. The policy is nowfont-src 'self',connect-src 'self'. - The dev page server (Vite) listens on this Mac only, on port 5173 exactly, and may
read only
frontend/, itsnode_modulesanddocs/— it can no longer servebackend/data/. - Left out on purpose (2026-11-08), with the reason:
- E-mail verification — the app sends no e-mail (a local-first install has no mail delivery); verifying addresses needs a decision on how mail would be sent. Until then the admin hands each one-time link over privately (see “Accepted” above).
- Download tokens — every download is a
fetchfrom the app’s own page with the session cookie (ui/ExportMenu→apiFetch,credentials: 'include'), saved as a blob; no page opens an API address directly (no<a href>orwindow.opento the API), and the shared app is one origin. A signed, short-lived token would add a second credential without closing a gap that exists today; it becomes worth it only if a download must open outside the page (a link in an e-mail, another application). 'unsafe-inline'for styles — React components setstyle={…}attributes (bar widths, chart positions, colours from data) and the motion library animates inline styles; a style nonce cannot cover attributes, so removing it would mean moving every dynamic style to classes or CSS variables set by script first. Scripts stay strictly'self'(no inline script, no eval).
- Still open (later): a running job keeps its submitter’s role after a role change; the certificate authority’s key is unencrypted on disk (unattended starts — its name constraints bound the damage); a cross-site top-level GET can still seed a stage.
- No secrets in the repo — nothing to leak;
.envholds paths, not credentials.
What is already hardened (input safety)
Even as a local tool, the file/import paths were audited and fixed (the “clean” column below is verified):
| Area | State |
|---|---|
| Assumption-book upload | Path-traversal fixed (fixed temp filename); size cap (413) + zip-bomb guard; non-zip → 422. |
| Excel export | Formula-injection neutralised (_safe_cell quote-prefixes = + - @ …). |
| HTML report | User strings HTML-escaped (_esc). |
| SQL | Parameterised throughout; table names regex-validated; columns are constants. |
| IDs | project_id server-generated + filesystem-safe. |
| Code | No eval/exec/pickle/yaml.load; frontend has no dangerouslySetInnerHTML. |
Before any hosting (the backlog)
These are prerequisites for a networked/multi-user deploy, tracked with criticality in the maintenance backlog. Highlights, roughly in order:
- Authentication + authorization — done on 2026-11-03: local sign-in, a principal on every request, every query scoped to its workspace, the role matrix; members, one-time links, the workspace switcher and read-only screens for viewers on 2026-11-04.
- Dependency + runtime upgrades — done on 2026-11-02: Python 3.12,
FastAPI on Starlette 1.x, pydantic 2,
python-multipart≥ 0.0.18, h11 0.16; the floors that close the known CVEs are pinned bytest_runtime_line.py. - Production hardening — HTTPS, security headers, exact origins on the shared
address, no
--reloadwhen shared and no raw error detail: done for the office Wi-Fi on 2026-11-04. On 2026-11-08: the Host check inside the app, exact localhost origins, a throttle that survives a restart, a default book per workspace, disabling an account from the screen, self-hosted fonts and a CSP with no outside origin. Hosting beyond one office is a separate decision; the container image that would serve it (behind a TLS proxy) is written, first build pending — Run with Docker. - Data architecture — real FK
ON DELETE CASCADE, lift the remaining stores behind repository interfaces before a MySQL move, a migrations + version-stamping path, absolute DAB paths.
Performance notes
- The feasibility LP solves a year in ~13 s (HiGHS IPM + crossover) and runs async so the UI never blocks — see the feasibility solve flow.
- The SOLVE-once / POST-PROCESS-on-read split keeps exploration instant after the one expensive solve.
- SQLite is single-writer, connection-per-call, in WAL mode (readers do not block the writer) — fine for one install; revisit with the repository-interface + MySQL move.
How to modify this page safely
- Keep it honest and current: if you add auth or hosting, this page must change
and its
status:flip. Don’t describe aspirations as if they’re implemented. - Cross-check the maintenance backlog before claiming anything is “clean.”
Related topics
From the app’s documentation, docs/06-operations/security-and-operations.md · Hyprisma 1.0.5