Skip to main content
User preferences are a per-user, string-keyed store of raw JSON values backing UI settings — panel sizes, last-opened tabs, density, and the like. They follow the user across browsers, devices, and workspaces (preferences are never workspace-scoped), and the endpoints below let the frontend list, upsert, and delete individual keys. The handler is deliberately schemaless on the value side. The frontend writes whatever JSON shape it wants under a key; the server stores the bytes and hands them back unchanged on read. This keeps UI iteration fast — adding a new preference doesn’t need a migration or a backend release. Implementation: internal/api/user_preferences.go. Persisted in user_preferences (migration v58) as (user_id, pref_key, pref_value, updated_at) with a UNIQUE(user_id, pref_key) constraint.

Endpoints

Auth and limits

  • All endpoints require an authenticated session.
  • The user_id is taken from the session — never from request bodies or query parameters.
  • Key constraint: [a-zA-Z0-9._-]{1,64}. Anything else returns 400. Keys land in URL paths, so this is a defensive parse before they reach the SQL layer.
  • Value cap: 16 KB per key. Larger payloads return 413 Request Entity Too Large.
  • There is no admin endpoint to read another user’s preferences.

List preferences

Returns every preference row for the authenticated user as a flat map of key → JSON value. Missing keys are simply absent from the map; the frontend falls back to its compiled-in defaults. Response: 200 OK
Values are parsed JSON — a number stays a number, an object stays an object. The handler round-trips through json.RawMessage so the FE doesn’t have to JSON-decode each value separately. Errors:

Set one preference

Upserts one key. The request body is the raw JSON value to store — not a {value: …} wrapper. So setting ui.theme to "dark" is:
And setting crews.bottom_panel_height:
Response: 204 No Content on success. Errors: The PUT is atomic via SQLite UPSERT (INSERT … ON CONFLICT(user_id, pref_key) DO UPDATE), so concurrent writes from two devices don’t lose data — last write wins.

Delete one preference

Request: No request body. The {key} path parameter identifies the preference to delete for the authenticated user. Removes the row for (user_id, key). Idempotent — deleting a non-existent key returns 204, not 404. The frontend’s “reset to default” action just deletes the key. Response: 204 No Content. Errors:

Conventional keys

The server does not enforce these names, but the frontend uses them consistently. Listing them here so integrators know what to read/write: Adding a new key is purely a frontend change — no API or migration work needed. Use the dotted-namespace convention (<surface>.<setting>) so a future grep stays scannable.