All method pages

MethodBackend

partial some parts checked against the code, others not yet · what the statuses mean

API reference — routes by resource

In simple terms

This is the catalogue of everything the frontend can ask the backend to do. Every route is thin: it parses the request, calls one PtxApi method (the orchestrator), and returns JSON. The base URL is http://localhost:8000 (set by VITE_API_BASE on the frontend). When the app is shared on the office Wi-Fi (serve.py --share, since 2026-11-04) the same routes are also served over HTTPS under /api — https://<mac>.local:8443/api/projects is GET /projects — beside the built app, on one origin; every path below is the same under the prefix. The container image serves them the same way, under /api beside the page (serve.py --behind-proxy, Run with Docker). Routes are declared in backend/api/app.py; the logic lives in backend/api/controllers.py.

Conventions. Bad input → 422 with a specific message (domain ValueError mapped at the route). Missing resource → 404. Long solves are async: a POST starts a job, a GET polls it. See the backend architecture and the feasibility solve flow.

The solve queue. At most PTX_SOLVE_WORKERS engine jobs run at once (core/solve_queue.py); a job started while every worker is busy waits its turn. Its status — in the start answer, in every job status route (GET /feasibility, …/sweep, …/right-size/landscape, …/finder/search|study|surface|grid, …/book/impact, …/runs/{kind}/last, the emissions screen) and in GET /projects/{id}/runs — is then "queued" with position (1 = the next to start) and ahead (how many jobs run or wait before it, every workspace’s counted, never named), and no progress; then "running" as before. In the runs list a queued run also has queued: true and keeps running: true (in flight) and stoppable. Stopping a queued job takes it out of the line; starting it again while it waits keeps its place. A request that answers within itself and runs the LP (Least cost; Scale the plant and Grid on against off on the solver; POST /feasibility/solve?wait=true) waits for its turn before it solves, and its run reads queued meanwhile. What it means for users: the feasibility suite, “When the server is busy”.

The sections below explain the main resources. The complete list — every route app.py declares, with the PtxApi method it calls — is the generated table in the section Every route, near the end.

Health & sites

Method · Path Does
GET /health Liveness check.
GET /sites List library sites (datasets nested as {wind, solar}), read-only.
GET /sites/{site_id} One site + its datasets.

Projects

Method · Path Does
GET /projects The signed-in workspace’s projects (a flat digest of each one’s design, who last edited it, its products with their pathway); ?home=1 adds each stage’s result digest — see The projects home.
POST /projects Create a project (guided essentials + optional site attach). Unknown derivative fields → 422.
GET /projects/{id} The full project record.
DELETE /projects/{id} Delete + full cascade (snapshots, book, both version axes, result files).
GET /projects/{id}/inputs The resolved sizing/topology + effective CFs + site provenance (what the rooms render).
PATCH /projects/{id}/config Update sizing (capacities, storage, derivatives, mode).
PUT /projects/{id}/site Link a site + per-carrier datasets (validates carrier/site membership).

Solve & optimise

Method · Path Does
POST /projects/{id}/solve Recompute prefeasibility against the book. ?mode=annual|timeseries overrides for this solve only (not persisted). Returns the SolveBundle.
POST /projects/{id}/right-size Run the right-size optimiser (body = locked components). Returns updated inputs.
POST /projects/{id}/right-size/landscape Start the async LCOA-surface computation (Mode B).
GET /projects/{id}/right-size/landscape Poll the landscape job → axes + grid + current/optimum points.

Assumption book (per project)

Method · Path Does
GET /projects/{id}/book The working book rows (value/unit/source/note + reference + default).
PATCH /projects/{id}/book Update values and/or notes. Values range-validated → 422 on breach.
GET /projects/{id}/book/export Download the book as the standard .xlsx.
POST /projects/{id}/book/import STRICT .xlsx import → diff preview. Upload capped (413) + zip-bomb guarded; filename ignored.

Version axes (book + sizing — identical shape)

Method · Path Does
GET /projects/{id}/versions · /sizing-versions List versions (active flagged).
POST …/versions/save Overwrite the active version with the working set.
POST …/versions/save-as Branch a new version (becomes active).
POST …/versions/{version_id}/switch Load a version into the working set → re-solve.
DELETE …/versions/{version_id} Delete a non-active version (never the last).

(The same five verbs exist under /sizing-versions for the sizing axis.)

Method · Path Role Does
GET /projects/{id}/sizing-versions/active/diff?stage= viewer The working design against its ACTIVE sizing version: the version’s own values (saved, flat — the keys the dirty flag compares, the two technology selectors when the version holds them, each product as derivatives.<kind>.<attr>, products) and the fields that differ now (rows: field, was, now). The design panel’s “was …” marks in prefeasibility (since 2026-11-08).

Feasibility (async LP + post-process reads)

Method · Path Does
POST /projects/{id}/feasibility/solve Start the async hourly LP (?wait=true for sync, after its turn in the solve queue). Answers the job: running, or queued with position and ahead while every worker is busy. See the flow.
GET /projects/{id}/feasibility Job status + persisted summary (?snapshot_id= for a frozen one): none · queued (with position, ahead) · running (with progress on a multi-year solve) · done · error. While queued or running it does not read the store, so no result. Never returns raw hours.
GET …/feasibility/dispatch ?res=day → 365 min/mean/max envelopes; ?res=hour&start=&end= → full-res window ≤ 21 days.
GET …/feasibility/duration Descending quantile samples for one series.
GET …/feasibility/series?name= One full-res series (the calendar heatmap; the approved raw-hours exception).
GET …/feasibility/monthly 12-bucket aggregates (reconciles to annual).
GET …/feasibility/reliability Contiguous unserved episodes + seasonal histogram + ride-through margins, and promise: each product delivered against the promise saved NOW (demand_t_yr; a snapshot: the one it captured), by the finder’s own rule, with the shortfall hours by cause — production and utilization only when nothing is promised (core/promise.py, since 2026-11-09).
POST …/feasibility/sweep Start a storage sweep (h2 / bess / both grid; per-axis start/step/points). Project never mutated.
GET …/feasibility/sweep Poll the sweep → per-size metrics + a best pick.
GET …/feasibility/frame?fmt=parquet|csv Download the raw hourly frame (snapshot-aware).

Emissions (since 2026-11-08)

Method · Path Role Does
GET /projects/{id}/emissions?stage= viewer The emissions SCREEN of that stage’s result (PtxApi.emissions, arithmetic in core/emissions/screen.py). Read-only: prefeasibility is solved on demand and stored nowhere; feasibility reads the stored solve, and a stage the project has not opened is not seeded (status: not_solved, or running while a solve runs, or queued with position and ahead while it waits for a worker). Returns rule (comparator 94, minimum saving 70 %, limit 28.2 gCO2e/MJ, the marginal band), grid (import, renewable-PPA share, carbon intensity, sources — the fossil/balancing part at the intensity and the PPA part at zero —, the total, what the PPA keeps out), products (per product: its share of the hydrogen used, the grid energy and CO2 charged to it, screen = rfnbo_check’s own dict, verdict + verdict_label, its grid budget from max_grid_mwh_for_compliance), the plant’s grid budget and the product that binds it, h2_renewable_fraction, basis (stage, engine, commitment, when solved, staleness), targets (RFNBO computed; IMO and EU Taxonomy listed as not computed) and not_modelled. The Grid tab’s RFNBO card is this screen’s largest product, so the two cannot disagree. A screen, not certification.

Snapshots

Method · Path Does
GET /projects/{id}/snapshots List snapshots for a project.
POST /projects/{id}/snapshots Capture a snapshot (book + config + a fresh solve).
GET /snapshots/{snapshot_id} One snapshot’s stored bundle.
POST /snapshots/{snapshot_id}/restore Write the snapshot’s book + config back onto the project.
GET /snapshots/{snapshot_id}/export/bundle Regenerate + download the .zip bundle.

Exports

Method · Path Does
GET /projects/{id}/export/results Regenerate + download the results workbook (.xlsx).
GET /projects/{id}/export/bundle Regenerate + download the full project bundle (.zip).

Exports are always regenerated on demand from stored inputs + results — never stored (see data model).

Default assumption book (DAB — per workspace, in Settings)

Each workspace has its own default books (since 2026-11-08). Every /dab/* route acts on the session’s workspace: it sees that workspace’s versions and the book shipped with the app (shipped-<book>, global, read-only); a version name of another workspace answers 404 — in the path, in ?version=, in a body (from_version, from_dab_version) and in the project’s book/dab-pull?version=. Changing a book is manage_dab (admins); reading and exporting is open to every role.

Method · Path Does
GET /dab/versions This workspace’s own versions, then the shipped books: version, label, is_active (what new projects here seed from — the workspace’s active book, else the current shipped one), shipped, read_only, current, book, created_by_name. No file path.
GET /dab/book One book’s rows (?version=; none = what new projects seed from), with shipped / read_only.
PATCH /dab/book Edit one of this workspace’s versions (range-validated; preserves file notes). A shipped book → 422 with what to do instead. The first change of a version whose file is a shipped file copies the file into the workspace’s own folder.
GET /dab/export · POST /dab/import Download any visible book; import (preview, then apply=true) into one of this workspace’s own versions only — a shipped book → 422.
POST /dab/versions {from_version?, label, make_active} A new version of this workspace, copied from one of its own or from a shipped book (none = what new projects here seed from).
POST /dab/versions/{version}/activate Make one of this workspace’s versions the seed of its new projects; on the CURRENT shipped book, go back to seeding from it. An older shipped book → 422.
DELETE /dab/versions/{version} Delete one of this workspace’s non-active versions (its file only if it lies in the workspace’s own folder). A shipped book → 422.

Editing a default book never mutates existing projects (they were frozen at create) — only future projects of the same workspace seed from it.

Every /workspace… route acts on the workspace the session is in — no workspace id appears in any path, so there is no other workspace to address. Switching (POST /me/workspace) is what changes it. The action each route needs is in backend/core/access.py; what only the owner may do is checked by one helper, access.require_owner. Services: backend/core/auth/service.py (switch, password, links) and backend/core/auth/members.py (the workspace’s members); stores: SQLiteMemberStore and SQLiteInvitationStore in backend/db/identity.py.

Route Who What
GET /me anyone signed in You, the workspace you act in (is_owner), your role, every workspace you belong to — the switcher’s source.
POST /me/workspace {workspace_id} anyone signed in Move this session into another of your workspaces; answers /me there. Not a member → 404.
POST /me/password {current_password, new_password} anyone signed in Change your password (at least 10 characters). Every OTHER session of yours ends; this one stays. Throttled like sign-in (429).
GET /workspace viewer Name, owner, your role, whether you own it.
PATCH /workspace {name} the owner Rename. Another admin → 403 naming the owner.
POST /workspace/transfer {user_id} the owner Hand the ownership to another admin member (else 422); you stay an admin.
POST /workspaces {name} admin A new workspace; you become its owner and admin; this session moves into it.
GET /workspace/members viewer Name, e-mail, role, owner flag of every member.
POST /workspace/members {email, role, display_name?} admin An account that can sign in joins at once → {added}. Anyone else → {invited, link_token, expires_at, invitation_id}: a one-time link, valid 7 days, accepted once. The token is in this answer only; the database stores its SHA-256. Already a member → 409.
PATCH /workspace/members/{user_id} {role} admin Change a role — never the owner’s, never leaving the workspace without an enabled admin (422 with the sentence).
DELETE /workspace/members/{user_id} admin Remove a member: their sessions in this workspace end with the membership; their account and their rows stay. Never the owner, never the last admin.
POST /workspace/members/{user_id}/disable · /enable admin Switch a member’s account off (every session of theirs ends at once; sign-in refused with the one sentence) or back on (since 2026-11-08). Only for a person who belongs to no other workspace — otherwise 422 with the sentence “remove them from this workspace instead”; never the owner, never yourself, never the last enabled admin (422). Not a member here → 404. Rows they made keep their author. An admin’s GET /workspace/members carries, per person, account: {can_disable, can_enable, blocked_reason, in_other_workspaces} — whether they are in another workspace, never which.
GET /workspace/invitations admin The links not used yet — never a token or its hash.
DELETE /workspace/invitations/{invitation_ref} admin Withdraw a link, by its non-secret id.
POST /workspace/invitations/{invitation_ref}/new-link admin A new token for a pending link (since 2026-11-04): the old link stops working at once; the new one is in this answer only, valid 7 days from now — {invited, link_token, expires_at, invitation_id} like Add. Not pending here (another workspace’s id included) → 404; the person has an account now → 422 with the sentence.
GET /sharing admin (manage_sharing) Whether colleagues on this network can open the app (since 2026-11-04): state off · on · moved (the Mac’s network changed under the shared address — no urls) · stopped (closed itself; sentence says why), urls {name, ip} while on, the command that shares, this Mac’s certificate (fingerprints SHA-256 + SHA-1, end date — public facts only) and the firewall’s state. Sharing is switched on by how the server starts, never by a request.
GET /sharing/certificate admin (manage_sharing) This Mac’s certificate authority’s public certificate (PtX-Model-local-CA.crt) for colleagues to install — never the key. Not made yet → 404 with the command.
GET /auth/invitation?token= public What a link is for (workspace, e-mail, role), or 404 with ONE sentence for a link that was used, withdrawn, expired or never existed. Throttled by client.
POST /auth/accept {token, display_name, password} public Use a link: the name and first password of its account (only if it has none), the membership, a session. Two accepts of one link: one wins. Throttled by client.

Lists also name their authors now: book and sizing versions, snapshots and the projects list carry created_by and created_by_name (null for a person the app does not know — never an error). A version names who saved it last.

Workspace administration — the super admin, deleting a workspace (since 2026-11-09)

The owner deletes their own workspace; the app’s super admin — the account that set the app up, others named only from the machine (backend/scripts/super_admin.py) — sees every workspace’s metadata and manages any of them. The super admin opens a workspace’s projects only by joining it, which its members see. Every /admin/… route answers anyone but the super admin 404 {"detail": "Not Found"} — exactly what an unknown address answers — before any id is looked at. Its rules were decided on 2026-11-08. Services: backend/core/auth/admin.py (who), PtxApi.delete_workspace and PtxApi.export_workspace_projects (what); store: SQLiteAdminStore in backend/db/admin.py; the backup: backend/db/backup.py.

Route Who What
GET /me anyone signed in Now also is_super_admin.
GET /workspace/delete-preview the owner What deleting this workspace removes: deletion.counts (projects, members, links, default books, sites…), members_left_without_workspace (they could no longer sign in), you_left_without_workspace, last_workspace, backup_folder (as ~/…), blocked (a sentence, or null). Another admin → 403 naming the owner.
GET /workspace/export/bundles the owner Every project as a project bundle (the bundle export, its confidential choke point included), in one zip with a manifest.json; a project that cannot be solved travels as data only and its entry says so. One that cannot even be read → 422 naming it (nothing downloaded).
DELETE /workspace {confirm_name} the owner Delete this workspace and everything in it. confirm_name must be its name (spaces around it aside) → else 422, before anything. Something running in it → 409. A backup of the whole database is written first and checked (ptx_projects-<stamp>-before-delete-workspace-<id>.db in PTX_BACKUP_DIR, its own default books copied beside it as …-default-books/) — when it cannot be: 503, nothing deleted. Then its sessions end, each project goes through the project delete cascade, its own rows go in one transaction, then its files. Answers what was removed, the backup’s name, the members left without a workspace, and session: moved (yours, to another workspace of yours — me there) · ended (you have none left) · unchanged. A stop after the backup → 500 with a sentence naming the backup.
GET /admin/workspaces super admin Every workspace: {workspace_id, name, created_at, owner {user_id, display_name, email}, member_count, project_count, last_activity_at, you {member, role, is_owner, current}} — never a project.
GET /admin/workspaces/{workspace_id} super admin The same for one, with its admins (who may become its owner) and deletion (as the owner’s preview; can_export only as a member).
PATCH /admin/workspaces/{workspace_id} {name} super admin Rename any workspace; its members are told when you are not its owner.
POST /admin/workspaces/{workspace_id}/transfer {user_id} super admin Make one of its admins its owner (else 422; not a member → 404); the old owner stays an admin; its members are told.
POST /admin/workspaces/{workspace_id}/join {role} super admin Become a member with that role (joined_as_super_admin), written with its event in one transaction — its members see it. Already one → 409. Answers me (the switcher now lists it).
POST /admin/workspaces/{workspace_id}/leave super admin Stop being a member, visibly; never its owner, never its last enabled admin (422). A session of yours acting there moves to another of your workspaces (or ends).
GET /admin/workspaces/{workspace_id}/export/bundles super admin, as a member As the owner’s export; not a member → 403 “join it first”.
DELETE /admin/workspaces/{workspace_id} {confirm_name} super admin As DELETE /workspace, for any workspace.

A workspace admin cannot switch the super admin’s account off (POST /workspace/members/{user_id}/disable → 422 with the sentence); removing them from the workspace works as for anyone.

The projects home (since 2026-11-06)

What the page everyone lands on after signing in reads and records. Recording your own visit, page, the welcome you closed and the What’s new notice you dismissed is the account action (a viewer may); reading is read. Everything acts in the session’s current workspace — but the What’s new notice is your account’s, in every workspace.

Route Who What
GET /projects?home=1 viewer The projects with each stage’s result digest (digests — the main product’s cost and unit, the CAPEX, every product’s cost and production, the capacities solved, when and by whom, and what changed since: design / assumptions, null when it cannot be told) and the headline (the furthest stage that has one). Every row also names who last edited it (updated_by_name) and its products with their pathway — without ?home=1 too.
GET /me anyone signed in Now also membership: joined_at, added_by / added_by_name (who added you; null: not recorded), last_visit_at, show_welcome (never for the workspace’s owner). And whats_new: {version, seen} — the running app’s version, and whether your account dismissed its What’s new notice (seen stays true for a later version you already dismissed).
POST /me/visit anyone signed in The home opened: {last_visit_at, first_visit, new_visit} — when your previous visit ended. A return within half an hour is the same visit (nothing the home shows changes).
POST /me/pages {project_id, path} anyone signed in The page of a project you opened (what Continue reopens): only one of that project’s own pages (/projects/<id>/…, 422 otherwise); a project of another workspace → 404.
GET /me/recent?limit= viewer Your last pages, one per project, newest first.
POST /me/welcome anyone signed in The one-time welcome was closed; the first time is kept.
POST /me/whats-new/seen anyone signed in The What’s new notice was dismissed: the running version is recorded for your account (in every workspace) until the next release; a later version already recorded is kept. Answers {version, seen}. Not in the activity log (navigation).
GET /activity?since=&limit= viewer What OTHER people did in this workspace since since (default: the end of your previous visit): book_version / sizing_version saved, snapshot taken, feasibility solve finished, project created, member joined — and, since 2026-11-09, the app’s super admin’s super_admin_joined (with the role) · super_admin_left · super_admin_renamed (detail.from/to) · super_admin_transferred (detail.from_name/to_name) — each with who, which project and stage, when; newest first, with the total. Your own actions and the automatic first versions are left out; nothing of another workspace is ever listed. A since that is not a time → 422.

The digests are written by the solves themselves: an editor’s or an admin’s own prefeasibility solve of the stage (POST /projects/{id}/solve without mode or stage; a viewer’s solve still stores nothing) and every stored feasibility solve, which also records meta.solved_by (dropped from every export). At start-up the server fills the digests a database lacks, in the background.

Every route

Generated from the decorators in backend/api/app.py (backend/scripts/gen_docs_numbers.py); — means the route answers without PtxApi.

Method Routes
GET 78
POST 65
PUT 1
PATCH 7
DELETE 12
Total 163
Method Path PtxApi method
GET /health —
GET /auth/state —
POST /auth/setup —
POST /auth/login —
POST /auth/logout —
GET /me —
GET /auth/invitation —
POST /auth/accept —
POST /me/workspace —
POST /me/password —
GET /workspace —
PATCH /workspace —
POST /workspace/transfer —
GET /workspace/delete-preview —
GET /workspace/export/bundles —
DELETE /workspace —
GET /admin/workspaces —
GET /admin/workspaces/{workspace_id} —
PATCH /admin/workspaces/{workspace_id} —
POST /admin/workspaces/{workspace_id}/transfer —
POST /admin/workspaces/{workspace_id}/join —
POST /admin/workspaces/{workspace_id}/leave —
GET /admin/workspaces/{workspace_id}/export/bundles —
DELETE /admin/workspaces/{workspace_id} —
POST /workspaces —
GET /workspace/members —
POST /workspace/members —
PATCH /workspace/members/{user_id} —
DELETE /workspace/members/{user_id} —
POST /workspace/members/{user_id}/disable —
POST /workspace/members/{user_id}/enable —
GET /workspace/invitations —
DELETE /workspace/invitations/{invitation_ref} —
POST /workspace/invitations/{invitation_ref}/new-link —
GET /sharing —
GET /sharing/certificate —
GET /sites list_sites
GET /sites/{site_id} get_site
GET /sites/template/timeseries timeseries_template
GET /sites/{site_id}/export/timeseries export_site_timeseries
POST /sites/import/timeseries import_timeseries_preview
GET /projects list_projects
POST /me/visit record_visit
POST /me/pages record_page
GET /me/recent recent_pages
POST /me/welcome mark_welcome_seen
POST /me/whats-new/seen mark_whats_new_seen
GET /activity activity
GET /workspace/audit —
GET /workspace/audit/export —
GET /admin/audit —
GET /admin/audit/export —
POST /projects create_project
GET /projects/examples list_examples
POST /projects/examples/{example_id} create_example_project
GET /projects/{project_id} get_project
DELETE /projects/{project_id} delete_project
PATCH /projects/{project_id}/identity update_identity
POST /projects/{project_id}/duplicate duplicate_project
GET /projects/{project_id}/inputs get_inputs
PATCH /projects/{project_id}/config update_config
PUT /projects/{project_id}/site set_project_site
POST /projects/{project_id}/solve solve
POST /projects/{project_id}/right-size right_size
GET /projects/{project_id}/versions list_versions
POST /projects/{project_id}/versions/save save_version
POST /projects/{project_id}/versions/save-as save_version_as
POST /projects/{project_id}/versions/{version_id}/switch switch_version
DELETE /projects/{project_id}/versions/{version_id} delete_version
GET /projects/{project_id}/sizing-versions list_sizing_versions
GET /projects/{project_id}/sizing-versions/active/diff active_sizing_diff
POST /projects/{project_id}/sizing-versions/save save_sizing_version
POST /projects/{project_id}/sizing-versions/save-as save_sizing_version_as
POST /projects/{project_id}/sizing-versions/{version_id}/switch switch_sizing_version
DELETE /projects/{project_id}/sizing-versions/{version_id} delete_sizing_version
GET /projects/{project_id}/book get_book
GET /projects/{project_id}/book/compare book_compare
POST /projects/{project_id}/book/impact start_book_impact
GET /projects/{project_id}/book/impact book_impact_status
GET /projects/{project_id}/book/dab-pull dab_pull_preview
POST /projects/{project_id}/book/dab-pull pull_dab
PATCH /projects/{project_id}/book update_book
GET /projects/{project_id}/documents documents
GET /projects/{project_id}/book/export export_book
POST /projects/{project_id}/book/import import_book_preview
GET /projects/{project_id}/export/design export_design
GET /projects/{project_id}/export/timeseries export_timeseries
POST /projects/import import_project_preview
POST /projects/{project_id}/feasibility/solve solve_feasibility_waiting
POST /projects/{project_id}/feasibility/optimize optimize_feasibility
POST /projects/{project_id}/feasibility/grid-impact grid_impact
POST /projects/{project_id}/feasibility/scale-compare scale_compare
POST /projects/{project_id}/feasibility/scale-sweep scale_sweep
POST /projects/{project_id}/feasibility/grid-sizing-impact grid_sizing_impact
GET /projects/{project_id}/runs list_runs
GET /projects/{project_id}/runs/{kind}/last last_run
POST /projects/{project_id}/runs/{run_id:path}/stop stop_run
POST /projects/{project_id}/feasibility/stop stop_feasibility_solve
GET /projects/{project_id}/feasibility feasibility_status
GET /projects/{project_id}/feasibility/flows flows_data
GET /projects/{project_id}/emissions emissions
GET /projects/{project_id}/resources —
GET /projects/{project_id}/resources/lifetime —
GET /projects/{project_id}/resources/compare —
GET /projects/{project_id}/feasibility/h2-failures —
GET /projects/{project_id}/feasibility/h2-failures/curve —
GET /projects/{project_id}/feasibility/monthly monthly_data
GET /projects/{project_id}/feasibility/dispatch dispatch_data
POST /projects/{project_id}/feasibility/sweep start_storage_sweep
GET /projects/{project_id}/feasibility/sweep storage_sweep_status
POST /projects/{project_id}/feasibility/sweep/stop stop_storage_sweep
POST /projects/{project_id}/right-size/landscape start_right_size_landscape
GET /projects/{project_id}/right-size/landscape right_size_landscape_status
GET /projects/{project_id}/feasibility/frame export_frame
GET /projects/{project_id}/feasibility/lifetime/export export_lifetime
GET /projects/{project_id}/feasibility/reliability reliability_data
GET /projects/{project_id}/feasibility/series series_data
GET /projects/{project_id}/feasibility/duration duration_data
GET /projects/{project_id}/snapshots list_snapshots
POST /projects/{project_id}/snapshots create_snapshot
GET /projects/{project_id}/stages/diff stage_diff
POST /projects/{project_id}/stages/pull stage_pull
POST /projects/{project_id}/stages/acknowledge stage_acknowledge
GET /snapshots/{snapshot_id} get_snapshot
POST /snapshots/{snapshot_id}/restore restore_snapshot
GET /snapshots/{snapshot_id}/export/bundle export_snapshot_bundle
GET /projects/{project_id}/export/results export_results
POST /projects/{project_id}/finder/size finder_size_to_target
POST /projects/{project_id}/finder/study start_finder_study
POST /projects/{project_id}/finder/search start_finder_search
GET /projects/{project_id}/finder/explore explore_axes
POST /projects/{project_id}/finder/explore finder_explore
POST /projects/{project_id}/finder/grid-caps grid_cap_scan
GET /projects/{project_id}/finder/search finder_search_status
GET /projects/{project_id}/export/finder-runs export_finder_runs
DELETE /projects/{project_id}/finder/search stop_finder_search
POST /projects/{project_id}/finder/surface start_finder_surface
POST /projects/{project_id}/design/apply apply_design
POST /projects/{project_id}/finder/apply finder_apply
GET /projects/{project_id}/finder/grid/setup finder_grid_setup
POST /projects/{project_id}/finder/grid start_finder_grid
GET /projects/{project_id}/finder/grid finder_grid_status
DELETE /projects/{project_id}/finder/grid stop_finder_grid
POST /projects/{project_id}/finder/grid/resume resume_finder_grid
GET /projects/{project_id}/export/finder-grid export_finder_grid
GET /projects/{project_id}/finder/surface finder_surface_status
DELETE /projects/{project_id}/finder/surface stop_finder_surface
GET /projects/{project_id}/finder/study finder_study_status
DELETE /projects/{project_id}/finder/study stop_finder_study
GET /projects/{project_id}/export/report export_report
POST /projects/{project_id}/export/report —
GET /projects/{project_id}/export/finder-report export_finder_report
POST /projects/{project_id}/export/finder-report —
GET /projects/{project_id}/export/excel-package export_excel_package
GET /projects/{project_id}/export/bundle export_bundle
GET /dab/versions list_dab_versions
GET /dab/book dab_book
GET /dab/export export_dab_book
POST /dab/import import_dab_preview
PATCH /dab/book update_dab_book
POST /dab/versions create_dab_version
POST /dab/versions/{version}/activate activate_dab_version
DELETE /dab/versions/{version} delete_dab_version

Security posture (honest)

Every route needs a signed-in session except GET /health, GET /auth/state, POST /auth/setup (the first run only), POST /auth/login (since 2026-11-03) and the two invitation-link routes, GET /auth/invitation and POST /auth/accept, where the link’s token is the credential (since 2026-11-04; the access log redacts token= values). One app-wide dependency (backend/api/auth.py) runs before every route: it turns the ptx_session cookie (HttpOnly, SameSite=Lax) into a principal — user, workspace, role — checks the route’s action against the role matrix (backend/core/access.py; a route missing from the matrix is refused), makes the principal current, and answers 404 for a project, snapshot, site or default-book version of another workspace (the same answer as an id that exists nowhere). Below it, every store method takes the workspace as a required argument. A state-changing request must carry X-PtX-Request: 1 and, when it says where it comes from, an allowed Origin — on 127.0.0.1 exactly the configured dev frontend origins (PTX_DEV_ORIGINS, default http://localhost:5173 and http://127.0.0.1:5173; since 2026-11-08 — no longer any localhost port). GET /projects lists the signed-in workspace’s projects only. In front of all of it, the app checks the Host (since 2026-11-08, backend/api/hosts.py): on plain HTTP only localhost / 127.0.0.1, over HTTPS only this Mac’s sharing names while sharing — any other name answers 400 before a route runs, however the server was started (a plain uvicorn … too). Failed sign-ins are counted in the database (login_failures), so a restart does not reset the throttle.

Over the shared HTTPS address (since 2026-11-04) the same guard runs under /api, with four differences: the cookie is __Host-ptx_session with Secure; a change must name its page (Origin) and that page must be one of exactly this Mac’s sharing addresses; no CORS headers are ever sent (one origin); the PTX_DEV_PRINCIPAL switch is ignored and POST /auth/setup answers 403. Every answer on both addresses carries the security headers (nosniff, Referrer-Policy: no-referrer, a Content-Security-Policy with frame-ancestors 'none' and — since 2026-11-08 — no outside origin at all (the data font ships with the app: font-src 'self'), X-Frame-Options, and HSTS over HTTPS); a 500 answers one sentence, never a traceback. Left out on purpose (2026-11-08, with the reasons on the security page): download tokens, e-mail verification, and dropping 'unsafe-inline' for styles.

How to modify this safely

  • New route = a thin app.py handler that parses + delegates to a new PtxApi method. No business logic in the route.
  • A new/changed response field is a three-place change: core/ producer → api.ts type → client.ts/room. Keep the ValueError → 422 convention.
  • Keep expensive work async (start-job + poll), and keep raw hours out of JSON responses except the sanctioned series route.

From the app’s documentation, docs/04-backend/api-overview.md · Hyprisma 1.0.5