Signing in and the frame
The API calls behind signing in and the frame.
On this page
- The order of calls at start-up
POST /api/session— sign inPUT /api/session/zone— what time zone this browser is inDELETE /api/session— sign outGET /api/me— who is lookingGET /api/me/details— your own account, in fullPUT /api/me/password— change your own passwordPOST /api/session/view-as— look as somebody elsePUT /api/settings/view-as— the repository-wide switchGET /api/licence— whose copy this isGET /api/health— liveness and readinessGET /api/people— everybody, and what each may do everywhereGET /api/stats— how big this isGET /api/metamodel— everything the UI needs to draw itselfGET /api/maintenance— what the maintenance page is drawn fromPUT /api/maintenance— switch itGET /api/brand— the word markGET /api/brand/logo— the logo bytesPOST /api/usage/event— what only the browser knowsGET /api/usage/state— is recording on
Part of the HTTP API reference.
The frame is everything outside #main: the sidebar, the top bar, the name in
the corner, the banners. It is drawn once at start-up by start and never by a route.
The order of calls at start-up
start makes these calls in exactly this order. Anything that
throws before /api/me lands on a signed-out screen.
GET /api/maintenance— first, before anything. Ifonand notmay_switch, the maintenance page is drawn and nothing else happens.- If the address is
#/invite/<token>:GET /api/invites/{token}, draw the invite screen, stop. - If the address is
#/signup/<token>:GET /api/brand, thenGET /api/signup/{token}, draw the confirm screen, stop. GET /api/me— "a 401 is not an error here, it is the front door". On 401, eitherGET /api/signup+ the sign-up form (if the address is#/signup) or the sign-in form.PUT /api/session/zone— tell the server which time zone this browser is in.GET /api/licence— never fatal; on failurestate.licence = null.GET /api/brand— before anything is drawn, so the corner is never briefly somebody else's.GET /api/health— "a database one schema behind serves the list pages fine and only breaks on detail, so it has to be checked".GET /api/metamodelGET /api/statsGET /api/peopleGET /api/usage/state— lazily, on the firstroute.route— the screen in the address bar.
POST /api/session — sign in
Called by drawSignIn.
Open. No session needed; it is how you get one.
Body
| Field | Type | Required | Notes |
|---|---|---|---|
username |
string | yes | stripped, lower-cased, matched on lower(username) |
password |
string | yes | compared with bcrypt |
Docstring: "Sign in. Deliberately slow to guess at, and vague about which half failed — except to somebody who has proved the password."
What is refused
| Condition | Status | Body |
|---|---|---|
| no such user, inactive, or no password hash | 401 | {"detail": "That username and password do not go together."} |
| wrong password | 401 | the same sentence, and failed_count is incremented and written to the signin log channel at level warn |
| no account yet, and the password matches a pending sign-up whose link is still live | 403 | {"detail": "Your account is not finished yet: the email we sent to <address> has a link in it, and following that link is what makes the account. Look in that inbox — the spam folder too — and open it."} |
| no account yet, and the password matches a pending sign-up whose link has expired | 403 | {"detail": "Your sign-up was never confirmed and its link has expired. …"} — sign up again |
failed_count >= 8 and the last failure was under 15 minutes ago |
429 | {"detail": "Too many attempts. Wait fifteen minutes, or ask for a new invite link."} |
| maintenance is on and this person cannot switch it | 503 | the maintenance body (see Conventions) |
"No such person" and "wrong password" told apart is a way to find out who works here, so they are one sentence — and the two 403s above do not break that, because they are said only once the password has been proved.
Why there are two answers, and when each is given
A sign-up lives in user_signup until its link is followed (migration 0038, so
that nobody can occupy an address they cannot read mail at). Until then there
is no app_user row, so "that username and password do not go together" was
literally true — and read, by the person who chose that password four minutes
ago, as "I have typed my own password wrong". They change it. They sign up
again. They get the same sentence.
So every failed sign-in now also looks for a pending sign-up for that address and checks the password against it. Only a match says anything. Whoever holds the right password already knows the address exists, because they chose it; there is nothing left to leak. A wrong password, and an address nobody has ever used, go on getting the one vague sentence.
The clock has to say nothing either, so the lookup costs one scrypt whichever
way it goes: against the pending row if there is one, and against a hash of a
random string made at import (_NOBODY) if there is not. Without that, a real
hash takes time and a missing one takes none, and a stopwatch answers what the
words refuse to. Measured interleaved, fifteen of each: 63.5 ms against
65.1 ms.
See also POST /api/signup in Signing up, and I signed up and cannot sign
in in Troubleshooting.
Response — the _me shape, plus the Set-Cookie:
{
"username": "r.iyer", // who the screens answer AS (the viewed-as person while viewing as)
"display_name": "Rohan Iyer", // their shown name
"signed_in_as": { // who you REALLY are, always
"username": "r.iyer",
"display_name": "Rohan Iyer"
},
"viewing_as": false, // true while looking through somebody else's eyes
"may_view_as": false, // holds VIEW_AS, and view-as is on (or they hold ACCESS_MANAGE)
"view_as_on": true, // the repository-wide switch
"may_manage_access": false, // holds ACCESS_MANAGE
"default_password": false // true only for the built-in superadmin still on its shipped password
}
Side effects — a user_session row; a signin logbook line naming the
masked network or its name if usage places are being recorded; a signin
usage event.
PUT /api/session/zone — what time zone this browser is in
Called by tellTheZone, immediately after /api/me succeeds.
Failures are swallowed by the client.
Body: {"zone": "Europe/London"} — stripped, truncated to 64 characters.
Validated against _ZONE_OK = ^[A-Za-z][A-Za-z0-9+_\-]*(/[A-Za-z0-9+_\-]+){0,2}$. A non-matching non-empty value is 400
{"detail": "'<zone>' is not a time zone name."}.
Docstring: recorded on the session and kept on the person as the last one seen, so an email — which nobody is present for — can still be written in the zone they last read the screen in. It changes nothing that is counted: a deadline is one moment, in the server's zone, however it is spelled out.
Response: {"zone": "Europe/London"}, or {"zone": null} for an empty
value or when there is no session.
DELETE /api/session — sign out
Called by profileMenu, inside a try/catch that goes anyway.
Open (the middleware lets it through without a session).
No body. Response {"signed_out": true} plus delete_cookie.
Sets revoked_at = now on the row, writes a signin logbook line, and
records a signout usage event carrying session_seconds — how long the
session lasted, for "session length" on the Usage screen.
GET /api/me — who is looking
Called at start-up and again after toggling view-as.
No parameters. Response is the _me shape above.
GET /api/me/details — your own account, in full
Called by detailsPanel, lazily, when the profile menu is opened.
Docstring: deliberately about the person who signed in, not the person
being viewed as — the menu it hangs off changes a password and ends a session,
and both belong to the real account. "A details panel that quietly described
somebody else while those two buttons acted on you would be a trap rather than
a screen." Read-only: changing somebody's name, email or roles is the access
model's job and needs ACCESS_MANAGE, which is not something you hold over
yourself by being yourself.
Response
{
"username": "r.iyer",
"display_name": "Rohan Iyer",
"email": "rohan@example.com", // may be null
"note": null, // the free-text note on the account
"is_active": true,
"auth_method": "password",
"password_set_at": "2026-02-01T…", // null if never set
"roles_everywhere": [ // roles held with asset_id IS NULL
{"name": "Steward", "code": "STEWARD",
"through_team": false, // true when it comes via a team
"team": null} // that team's name, when it does
],
"roles_on_assets": [ // CAPPED AT 12
{"role": "Owner", "asset": "Customer", "uid": "…",
"type": "Data Domain", "through_team": false, "team": null}
],
"roles_on_assets_total": 37, // the real number, uncapped
"teams": ["Data Governance"],
"permissions": ["ASSET_EDIT", "ASSET_VIEW", …], // everything, everywhere
"session": { // THIS session
"created_at": "…", "last_seen_at": "…",
"expires_at": "…", "agent": "Mozilla/5.0 …"
},
"live_sessions": 2, // unrevoked, unexpired sessions for this account
"viewing_as": null, // the display_name of whoever you are looking as
"default_password": false
}
roles_on_assets is capped at 12 because "a steward of two hundred columns
does not want them listed in a corner panel"; the total is given beside it.
PUT /api/me/password — change your own password
Called by passwordForm.
Docstring: until this existed the only way to set a password was an invite link, which made "change the default one" advice nobody could follow without a terminal. The old password is required "so a borrowed screen cannot be turned into a permanent account takeover."
Body
| Field | Type | Required | Notes |
|---|---|---|---|
current |
string | yes | checked against the stored hash |
new |
string | yes | at least 10 characters |
Refusals
| Condition | Status | Message |
|---|---|---|
len(new) < 10 |
400 | "A password needs at least ten characters. Three words you would not forget beats something clever you would." |
current is wrong |
403 | "That is not your current password." |
new is the same as the old |
400 | "That is the password you already have." |
Length and nothing else: character classes push people towards Passw0rd!.
Side effects: the hash is replaced, failed_count/failed_at cleared,
the superadmin default-password flag cleared, and every other session for
that account is revoked — "including the copy somebody else may be holding,
which is half the reason to change a password at all". This browser keeps its
session.
Response: the _me shape plus "other_sessions_ended": <n>.
POST /api/session/view-as — look as somebody else
Called by viewAs.
Body: {"username": "a.khan"}. An empty or missing username stops
viewing as anybody and returns to yourself.
Permission: VIEW_AS. And if the repository-wide switch is off, also
ACCESS_MANAGE.
Refusals
| Condition | Status | Message |
|---|---|---|
no VIEW_AS |
403 | "Looking at the repository as somebody else needs VIEW_AS, which is a tick against a role in the access workbook." |
switch off, no ACCESS_MANAGE |
403 | "Viewing as somebody else is switched off for this repository. Whoever manages access can turn it back on." |
| no such active person | 404 | "There is nobody called <username> here." |
| it is you | 400 | "You are already yourself." |
Response: the _me shape, now with viewing_as: true.
Read-only is enforced in the middleware, not here, "so it covers every endpoint including the ones nobody has written yet."
PUT /api/settings/view-as — the repository-wide switch
Called by viewAsSwitch on Access → People, which then re-reads
/api/me.
Permission: ACCESS_MANAGE — "change how access works here".
Body: {"on": true} — any truthy value means on.
Response: {"view_as": "on"} or {"view_as": "off"}.
GET /api/licence — whose copy this is
Called at start-up, inside a try/catch that leaves
state.licence = null. The banner is shown only when state !== "valid" —
"a working licence is not news, and a banner nobody needs is a banner
everybody learns to ignore".
Open, and answered even when the licence is the reason nothing else is: "a screen that cannot say why it is refusing is a screen nobody can fix."
Response — licence.summary(host) plus files:
{
"state": "valid", // valid | wrong_host | expired | … (from licence.py)
"why": null, // the sentence to show when it is not valid
"files": { // licence.manifest_check
"state": "unchanged", // or "changed"
"changed": ["app.py", …] // files altered since the release was built
}
}
The remaining keys come from licence.py and are [not documented] here —
this file reads them as an opaque summary.
GET /api/health — liveness and readiness
Called at start-up. bash run.sh doctor calls it too.
Open. No parameters.
Response: {"status": "ok", "schema": 91, "asset_types": 33}.
The client puts schema in the corner on purpose: "'Am I looking at the new
build or a cached one' is otherwise unanswerable from the screen, and it is the
first thing to check when something that was just added is not there."
Refusals — all 503 with the NotReady shape:
| Condition | detail | remedy |
|---|---|---|
no repository_version table |
"This database predates schema versioning." | "Run: bash run.sh setup" |
| version is NULL | "The database is on schema vNone; this build expects v91." | "Nothing has stamped a version here. Run: bash run.sh setup on an empty database, or restore a backup." |
| version below 82 | same sentence | "Run: bash run.sh upgrade — it applies what is missing and keeps everything in the repository." |
| version above 82 | same sentence | "This build is older than the database. Deploy the build that expects v<found>, or later; do not run setup, which would rebuild the database from this build's schema." |
asset_type is empty |
"The metamodel is empty." | "Run: bash run.sh reload" |
The behind/ahead distinction is deliberate and was a bug: "It said setup for
both, and somebody read it mid-upgrade" — setup rebuilds from schema.sql,
which would throw the repository away to fix a gap upgrade closes in seconds.
GET /api/people — everybody, and what each may do everywhere
Called at start-up, again by Access, and by the responsibility and action pickers .
Docstring: "The per-asset answer is a different question and is asked per asset; this is only enough to populate the picker and to grey out what a person could never do anywhere."
Response
{"people": [
{"username": "r.iyer",
"display_name": "Rohan Iyer",
"everywhere": ["ASSET_EDIT", "ASSET_VIEW"], // permissions_of(u, NULL), sorted; null if none
"on_assets": 12} // how many responsibilities they hold ON an asset
]}
Active people only, ordered by display_name.
GET /api/stats — how big this is
Called at start-up and by the Overview screen.
Cached by _derived on the change log: "6.17s on ten million assets, for four
unbounded counts and a group-by over the whole estate, every time anybody
opened the dashboard."
Response
{
"assets": 10001150, // count(*) FROM asset — proposals included
"attributes": 412, // count(*) FROM asset_attribute
"relations": 9800231, // count(*) FROM relation
"asset_types": 23,
"relation_types": 31,
"by_type": [ // every type with at least one asset, biggest first
{"code": "COLUMN", "n": 9500000}
],
"gaps": [ // mandatory attributes with no value — "the only quality signal Phase 0 owes you"
{"asset_type": "BUSINESS_TERM", "attribute": "DEFINITION", "missing": 14}
]
}
GET /api/metamodel — everything the UI needs to draw itself
Called at start-up and re-read after any metamodel edit
(mmRefresh).
Docstring: "One call, cached by the client." Also cached server-side by
_derived — 5.25s on ten million assets, "and the client caches it too, and
that was never the problem — the cost was paid again on every cold client."
Response
{
"asset_types": [
{"id": 4, "code": "TABLE", "name": "Table", "plural": "Tables",
"type_group": "Physical", // the layer/band it belongs to
"origin": "harvested", // harvested | authored
"description": "…", "icon": "table",
"instance_count": 412} // assets of this type, proposals excluded
],
"fields": { // keyed by asset type code
"TABLE": [
{"code": "ROW_COUNT", "name": "Row count", "data_type": "number",
"allowed_values": null, // the list, for data_type "choice"
"is_mandatory": false, "note": null, "origin": "harvested"}
]
},
"relation_types": [
{"code": "SCHEMA_HOLDS_TABLE", "source_type": "SCHEMA",
"forward_label": "holds", "target_type": "TABLE",
"inverse_label": "sits in",
"source_to_target": "1:N", "target_to_source": "1:1",
"cardinality": "1:N", "origin": "harvested", "note": null}
],
"property_labels": {"row_count": "Row count", …}, // PROPERTY_LABELS
"harvested_keys": ["owner", "source_tags", …], // properties that are attributes now
"rollup_types": ["DATA_DOMAIN"] // which types things roll up to
}
harvested_keys exists so the asset page does not show the same fact twice.
What the whole thing is for: the client file's own opening comment says "the only thing worth checking in this file is what it does NOT contain: no asset type is named anywhere."
GET /api/maintenance — what the maintenance page is drawn from
Called first at start-up and by the signed-out router.
Open. No session needed: "it is the one thing that answers when nothing
else does." Reads the state with force=True, bypassing the five-second
cache.
Response
{
"on": false, // the switch, OR inside the window
"message": "", // what the page says
"back_at": null, // an ISO time somebody promised
"contact": "support@…", // falls back to licence.SUPPORT_EMAIL
"from": null, // the window's start, ISO
"until": null, // the window's end, ISO
"by_window": false, // true when `on` is because of the window, not the switch
"may_switch": false // whether the person asking holds ACCESS_MANAGE
}
A window with no start is not "always": it is a window that ends. may_switch is false whenever there is no session.
PUT /api/maintenance — switch it
Called by acMaintenance on Access → People.
Permission: ACCESS_MANAGE — "switch the maintenance page".
Body — every field optional; at least one required.
| Field | Type | Stored as | Notes |
|---|---|---|---|
on |
bool | maintenance = "on"/"off" |
|
message |
string | maintenance_message |
stripped, first 600 chars |
contact |
string | maintenance_contact |
stripped, first 200 chars |
back_at |
ISO datetime | maintenance_back_at |
parsed by fromisoformat, UTC assumed if naive |
from |
ISO datetime | maintenance_from |
as above |
until |
ISO datetime | maintenance_until |
as above |
Refusals
| Condition | Status | Message |
|---|---|---|
| a date that will not parse | 400 | "'<v>' is not a date and time. Write it as 2026-09-30T21:00." |
from > until |
400 | "The window ends before it starts." |
| nothing recognised in the body | 400 | "Nothing to change." |
Docstring: "Switching it ON ends everybody else's session. Somebody
halfway through an edit when a migration starts is the case this exists to
prevent, and a session left open is an edit waiting to be saved into the middle
of it." The revoke is WHERE NOT may(username, 'ACCESS_MANAGE', NULL).
Response: the same shape as the GET, with may_switch: true and
"sessions_ended": <n>.
GET /api/brand — the word mark
Called at start-up, by drawBrand, by the
sign-in, sign-up and maintenance screens, and by the Appearance screen.
Open, GET only. "A product that shows somebody else's name to the one person who has not signed in yet is a product that looks broken at the only moment it has to look trustworthy."
Response
{
"name": "Acme Metadata", // brand_name, or "KRISID"
"has_logo": true, // whether brand_logo holds anything
"logo_type": "image/png", // the sniffed type of the stored bytes
"bar": "dark" // "dark" | "light" — how the top bar is drawn
}
The stored base64 never leaves this endpoint; only has_logo.
GET /api/brand/logo — the logo bytes
Used as an <img src> by the sign-in, sign-up, maintenance and Appearance
screens, cache-busted with ?v=<timestamp> where a fresh upload must be shown.
Open, GET/HEAD only.
Response: the raw bytes with the media type sniffed again on the way
out — "a row somebody reached with SQL is a row this endpoint would
otherwise serve as whatever the neighbouring setting claimed." Headers:
X-Content-Type-Options: nosniff, Cache-Control: no-cache, and an ETag of
the first 32 hex characters of the SHA-256 of the bytes.
Refusals: 404 "No logo has been uploaded."; 500 "The stored logo cannot
be read." (bad base64); 500 "The stored logo is not an image." (sniff
failed).
POST /api/usage/event — what only the browser knows
Called by usageSend on every navigation, on pagehide and on
visibilitychange. Sent with navigator.sendBeacon where available, else a
fetch with keepalive. The client only sends it when /api/usage/state said
recording is on.
Body — one of two kinds.
{"kind": "screen", "screen": "asset", "target": "<uid>", "duration_ms": 41230}
screen is truncated to 60 characters; duration_ms is clamped to
0 … 8 * 3600 * 1000 (eight hours), and anything unparseable becomes 0.
{"kind": "search_open", "q": "where is customer email"}
This increments detail.opened on the most recent search event for this
person with that exact (case-insensitive) target — "an answer opened from
Ask: the search it came from was not a dead end". Recorded only
when recording is on, there is a session, and the session is not viewing as
somebody else.
Any other kind is ignored.
Response: {"ok": true}, always.
GET /api/usage/state — is recording on
Called lazily on the first route (usageLoad) and by the
Usage screen.
Response
{
"on": true, // usage_tracking
"keep_days": 180, // usage_keep_days, default 180
"places": false, // usage_places — recording the network people sign in from
"may_view": false, // holds USAGE_VIEW
"may_manage": false, // holds USAGE_MANAGE
"events": 184203, // count(*) FROM usage_event — null without USAGE_VIEW
"since": "2026-04-01T…" // min(at) — null without USAGE_VIEW
}
events and since are withheld rather than zeroed when the reader may not
see usage.