Docs › Reference

For everyone

Signing in and the frame

The API calls behind signing in and the frame.

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.

  1. GET /api/maintenance — first, before anything. If on and not may_switch, the maintenance page is drawn and nothing else happens.
  2. If the address is #/invite/<token>: GET /api/invites/{token}, draw the invite screen, stop.
  3. If the address is #/signup/<token>: GET /api/brand, then GET /api/signup/{token}, draw the confirm screen, stop.
  4. GET /api/me — "a 401 is not an error here, it is the front door". On 401, either GET /api/signup + the sign-up form (if the address is #/signup) or the sign-in form.
  5. PUT /api/session/zone — tell the server which time zone this browser is in.
  6. GET /api/licence — never fatal; on failure state.licence = null.
  7. GET /api/brand — before anything is drawn, so the corner is never briefly somebody else's.
  8. GET /api/health — "a database one schema behind serves the list pages fine and only breaks on detail, so it has to be checked".
  9. GET /api/metamodel
  10. GET /api/stats
  11. GET /api/people
  12. GET /api/usage/state — lazily, on the first route.
  13. 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.