This is the complete REST surface mounted by backend/remote.Mount on the /api prefix. Every route except POST /api/login is gated by AuthGate. Every state-changing route except bearer-keyed callers is gated by OriginGuard.

DTO field names below are the JSON tags from backend/api; types are the Go-side shape. Where a DTO appears in multiple routes, its full schema is shown once at the top of its section.

CSRF carve-out reminder. OriginGuard allows requests that have neither an Origin nor a Referer header — SameSite=Strict on the session cookie means a real browser-mediated CSRF wouldn’t carry the cookie, and most non-browser clients omit both headers. Cookie-authed requests are not unconditionally Origin-checked. See Authentication for the full algorithm.

Authentication

POST /api/login

Public. Rate-limited per IP (5/min, burst 5). Issues a session cookie on success.

Request body

{ "username": "string", "password": "string" }

Responses

  • 200 {"ok": true} — sets mosaic_session cookie
  • 400 body decode failure
  • 401 {"error": "invalid credentials"}
  • 429 {"error": "too many login attempts"}Retry-After: 12 is a constant (12 seconds) regardless of how many tokens are missing from the bucket; clients should treat it as “wait at least 12s and try once”.
curl -k -c cookies.txt -X POST https://localhost:8080/api/login \
  -H "Content-Type: application/json" \
  -d '{"username":"admin","password":"hunter2"}'

POST /api/logout

Auth: session OR bearer. Origin guard applies.

Request body: none.

Response: 200 {"ok": true}. Deletes the server-side session and clears the cookie.


Torrents

TorrentDTO shape

{
  id: string;             // info-hash
  name: string;
  magnet: string;
  save_path: string;
  total_bytes: number;
  bytes_done: number;
  progress: number;       // 0..1
  download_rate: number;  // bytes/sec
  upload_rate: number;    // bytes/sec
  peers: number;
  seeds: number;
  paused: boolean;
  completed: boolean;
  added_at: number;       // unix seconds
  category_id: number | null;
  tags: TagDTO[];
  queue_position: number;
  force_start: boolean;
  queued: boolean;        // capped by queue limits
  verifying: boolean;     // recheck in progress
  files_missing: boolean; // on-disk pieces vanished between sessions
}

GET /api/torrents

List all torrents. Stable sort: newest-first by added_at, tie-break by id.

Response: 200 [TorrentDTO, ...]

curl -k https://localhost:8080/api/torrents \
  -H "Authorization: Bearer $MOSAIC_API_KEY"

POST /api/torrents/magnet

Add a torrent by magnet URI. Persists the magnet so it survives restart.

Request body

{ "magnet": "magnet:?xt=urn:btih:...", "save_path": "/optional/path" }

save_path is optional — empty defaults to GetDefaultSavePath().

Responses

  • 200 {"id": "<info-hash>"}
  • 400 invalid magnet, engine refused, or persistence failed
curl -k -X POST https://localhost:8080/api/torrents/magnet \
  -H "Authorization: Bearer $MOSAIC_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"magnet":"magnet:?xt=urn:btih:...&dn=...","save_path":""}'

POST /api/torrents/file

Add a torrent by uploading a .torrent file. multipart/form-data only; max 10 MiB body.

Form fields

  • file (file) — the .torrent blob
  • save_path (string, optional) — defaults to the global default

Responses

  • 200 {"id": "<info-hash>"}
  • 400 parse / read / engine errors
curl -k -X POST https://localhost:8080/api/torrents/file \
  -H "Authorization: Bearer $MOSAIC_API_KEY" \
  -F "file=@./ubuntu.torrent" \
  -F "save_path=/srv/downloads"

POST /api/torrents/{id}/pause

{id} is the info-hash. Response: 200 {"ok": true}.

POST /api/torrents/{id}/resume

Response: 200 {"ok": true}.

POST /api/torrents/{id}/recheck

Forces piece-by-piece reverification against the on-disk files.

Response: 200 {"ok": true}.

DELETE /api/torrents/{id}

Remove a torrent. Query param delete=1 also wipes the on-disk files; otherwise files are left in place.

Response: 200 {"ok": true}.

curl -k -X DELETE "https://localhost:8080/api/torrents/$INFOHASH?delete=1" \
  -H "Authorization: Bearer $MOSAIC_API_KEY"

POST /api/torrents/category

Set or clear the category for a torrent.

Request body

{ "infohash": "string", "category_id": 7 }

category_id: null clears the category.

Response: 200 {"ok": true}.

POST /api/torrents/file_priorities

Set per-file piece priorities for a torrent.

Request body

{
  "infohash": "string",
  "priorities": { "0": "skip", "1": "normal", "2": "high", "3": "max" }
}

Keys are the file index (as string in JSON); values are one of "skip" | "normal" | "high" | "max". Any string outside that set is treated as "normal".

Response: 200 {"ok": true}.

POST /api/torrents/queue_position

{ "infohash": "string", "pos": 0 }

Lower pos means higher priority in the queue. Response: 200 {"ok": true}.

POST /api/torrents/force_start

{ "infohash": "string", "force": true }

force=true bypasses the queue limit for this torrent. Response: 200 {"ok": true}.


Stats

GET /api/stats

Response: 200 GlobalStats

{
  total_torrents: number;
  active_torrents: number;   // count of torrents where !Paused && !Completed
  seeding_torrents: number;  // count of torrents where Completed === true (regardless of Paused)
  total_download_rate: number; // bytes/sec
  total_upload_rate: number;
  total_peers: number;
}

Inspector

The inspector is a per-client focus channel: tell the backend which torrent the user is looking at and which tabs are visible, and the inspector:tick WebSocket frame will scope to just that detail.

POST /api/inspector/focus

{
  "id": "<info-hash>",
  "tabs": ["overview", "files", "peers", "trackers", "speed"]
}

Empty id clears the focus. Tabs control which optional sections of DetailDTO get populated:

  • "files" populates files[]
  • "peers" populates peers_list[]
  • "trackers" populates trackers[]
  • "overview" and "speed" are accepted by the request body but are inert — scopeForTabs doesn’t set any additional flags for them. Pass them or omit them; the resulting payload is the same.

Response: 200 {"ok": true}.

POST /api/inspector/clear

No body. Response: 200 {"ok": true}.

DetailDTO shape

{
  id: string;
  name: string;
  magnet: string;
  save_path: string;
  total_bytes: number;
  bytes_done: number;
  progress: number;
  ratio: number;
  total_down: number;       // lifetime bytes downloaded
  total_up: number;         // lifetime bytes uploaded
  peers: number;
  seeds: number;
  added_at: number;
  files?: FileDTO[];
  peers_list?: PeerDTO[];
  trackers?: TrackerDTO[];
}

// FileDTO
{ index: number; path: string; size: number; bytes_done: number; progress: number; priority: "skip"|"normal"|"high"|"max" }

// PeerDTO
{ ip: string; port: number; client: string; flags: string; progress: number; download_rate: number; upload_rate: number; country: string }

// TrackerDTO
{ url: string; status: string; seeds: number; peers: number; downloaded: number; last_announce: number; next_announce: number }

DetailDTO is delivered through the inspector:tick WebSocket frame — there’s no REST GET for it. See WebSocket.


Categories

CategoryDTO

{ id: number; name: string; default_save_path: string; color: string }

GET /api/categories

Response: 200 [CategoryDTO, ...].

POST /api/categories

{ "name": "Movies", "default_save_path": "/media/movies", "color": "#7c3aed" }

Response: 200 {"id": 1}.

PUT /api/categories

{ "id": 1, "name": "Movies", "default_save_path": "/media/movies", "color": "#7c3aed" }

Response: 200 {"ok": true}.

DELETE /api/categories/{id}

Response: 200 {"ok": true}.


Tags

TagDTO

{ id: number; name: string; color: string }

GET /api/tags

Response: 200 [TagDTO, ...].

POST /api/tags

{ "name": "linux-iso", "color": "#67e8f9" }

Response: 200 {"id": 4}.

DELETE /api/tags/{id}

Response: 200 {"ok": true}.

POST /api/tags/assign

{ "infohash": "string", "tag_id": 4 }

Response: 200 {"ok": true}.

POST /api/tags/unassign

Same body shape as assign. Response: 200 {"ok": true}.


Settings: save path

GET /api/settings/save_path

Response: 200 {"path": "/Users/me/Downloads"}.

PUT /api/settings/save_path

{ "path": "/srv/downloads" }

Response: 200 {"ok": true}.


Settings: bandwidth limits

LimitsDTO

{
  down_kbps: number;
  up_kbps: number;
  alt_down_kbps: number;
  alt_up_kbps: number;
  alt_active: boolean;
}

0 for either kbps field means no limit.

GET /api/settings/limits

Response: 200 LimitsDTO.

PUT /api/settings/limits

Body: LimitsDTO. Response: 200 {"ok": true}.

POST /api/settings/alt_speed/toggle

Flip the alt-speed override. Response: 200 {"alt_active": true} (the new state).


Settings: queue

QueueLimitsDTO

{ max_active_downloads: number; max_active_seeds: number }

0 means unlimited (the queue scheduler is bypassed).

GET /api/settings/queue_limits

Response: 200 QueueLimitsDTO.

PUT /api/settings/queue_limits

Body: QueueLimitsDTO. Response: 200 {"ok": true}.


Settings: peer

PeerLimitsDTO

{
  listen_port: number;            // 0 lets anacrolix pick at startup
  max_peers_per_torrent: number;  // 0 = anacrolix default (80)
  dht_enabled: boolean;
  encryption_enabled: boolean;
}

max_peers_per_torrent mutates at runtime; listen_port / dht_enabled / encryption_enabled take effect on next launch.

GET /api/settings/peer_limits

Response: 200 PeerLimitsDTO.

PUT /api/settings/peer_limits

Body: PeerLimitsDTO. Response: 200 {"ok": true} or 400 if listen_port is outside 0..65535 or max_peers_per_torrent is negative.


Settings: blocklist

BlocklistDTO

{
  url: string;
  enabled: boolean;
  last_loaded_at: number; // unix seconds, 0 if never
  entries: number;        // newline count of last fetch
  error?: string;
}

GET /api/settings/blocklist

Response: 200 BlocklistDTO.

PUT /api/settings/blocklist

{ "url": "https://example.com/blocklist.p2p", "enabled": true }

url is validated against the SSRF allow-list (http/https only, no private/loopback addresses). Disabling clears the in-memory blocklist state and tells the engine to drop its filter. Enabling kicks off an immediate fetch.

Response: 200 {"ok": true} or 400 with the validation error.

POST /api/settings/blocklist/refresh

Re-fetch the configured blocklist URL. Body cap at 50 MiB; 30s timeout.

Responses

  • 200 {"ok": true} on success
  • 400 if no URL is configured / URL re-validation failed
  • 502 on upstream failure

Settings: web interface

WebConfigDTO

{
  enabled: boolean;
  port: number;
  bind_all: boolean;
  username: string;
  api_key: string;
}

api_key is read-only here — rotate it via the dedicated route.

GET /api/settings/web

Response: 200 WebConfigDTO.

PUT /api/settings/web

Body: WebConfigDTO. The server only persists enabled, port, bind_all, and username from the request body — api_key is silently ignored on this route (rotate the key via the dedicated POST /api/settings/web/api_key/rotate endpoint instead). Changing username triggers a server-side RevokeAll so any pre-change cookies stop working. Restarts the underlying remote.Server if the bind/port/enabled state changed.

Response: 200 {"ok": true}.

PUT /api/settings/web/password

{ "password": "<new-plaintext>" }

Hashes with Argon2id, persists, calls RevokeAll. Response: 200 {"ok": true}.

POST /api/settings/web/api_key/rotate

Generates a fresh 32-byte token, persists, returns it.

Response: 200 {"api_key": "<token>"}. The token is base64.RawURLEncoding-encoded (URL-safe alphabet, no padding) — characters are drawn from [A-Za-z0-9-_].


Settings: updater

UpdaterConfigDTO

{
  enabled: boolean;
  channel: "stable" | "beta";
  last_checked_at: number;   // unix seconds
  last_seen_version: string; // last "available" version observed
}

GET /api/settings/updater

Response: 200 UpdaterConfigDTO.

PUT /api/settings/updater

Body: UpdaterConfigDTO (server only persists enabled + channel). Validates channel is "stable" or "beta".

Response: 200 {"ok": true} or 400 {"error": "channel must be stable or beta"}.

POST /api/updater/check

Forces a release check and returns the result.

Response: 200 UpdateInfoDTO

{
  available: boolean;
  latest_version: string;
  asset_url: string;
  asset_filename: string;
  checked_at: number;       // unix seconds
  current_version: string;  // build-time version
}

500 with {"error":"updater disabled"} if AttachUpdater was never called (test/headless builds).

POST /api/updater/install

Runs the install with SHA-256 manifest verification. On success, fires the desktop “update installed” notification (if a Notifier is attached and the user hasn’t disabled it).

Response: 200 {"ok": true} on success; 500 on failure.

GET /api/version

Response: 200 {"version": "v0.2.9"}. The build-time version string.


Schedule rules

Time-of-day bandwidth rules. When any rule’s window overlaps now, its limits override the global LimitsDTO. When multiple enabled rules match the current time, the engine picks one — the current implementation defers to the rule query order from SQLite. Treat the conflict-resolution behavior as undefined and avoid configuring overlapping rules.

ScheduleRuleDTO

{
  id: number;
  days_mask: number;   // bit 0 = Sunday, bit 6 = Saturday; 127 = all days
  start_min: number;   // minutes since midnight, 0..1439
  end_min: number;     // minutes since midnight; if < start_min, wraps midnight
  down_kbps: number;
  up_kbps: number;
  alt_only: boolean;   // when true, just enables alt-speed instead of overriding
  enabled: boolean;
}

GET /api/schedule_rules

Response: 200 [ScheduleRuleDTO, ...].

POST /api/schedule_rules

Body: ScheduleRuleDTO (id is ignored). Response: 200 {"id": <int>}.

PUT /api/schedule_rules

Body: ScheduleRuleDTO. Response: 200 {"ok": true}.

DELETE /api/schedule_rules/{id}

Response: 200 {"ok": true}.


RSS feeds

FeedDTO

{
  id: number;
  url: string;          // http(s); SSRF-validated
  name: string;
  interval_min: number; // minutes between polls; 0 → service default
  last_polled: number;  // unix seconds, 0 if never
  etag: string;         // last ETag seen, used as If-None-Match
  enabled: boolean;
}

GET /api/feeds

Response: 200 [FeedDTO, ...].

POST /api/feeds

Body: FeedDTO (id ignored). URL validated through validateFetchURL.

Response: 200 {"id": <int>}, or 400 with the validation error.

PUT /api/feeds

Body: FeedDTO. Same URL validation as create.

Response: 200 {"ok": true} or 400.

DELETE /api/feeds/{id}

Response: 200 {"ok": true}.


RSS filters

Filters live under a feed and gate which items get auto-added.

FilterDTO

{
  id: number;
  feed_id: number;
  regex: string;             // POSIX/Go regexp; empty = match all
  category_id: number | null;
  save_path: string;         // empty = inherit category default → global default
  enabled: boolean;
}

GET /api/feeds/{feedID}/filters

Response: 200 [FilterDTO, ...].

POST /api/filters

Body: FilterDTO (id ignored). Response: 200 {"id": <int>}.

PUT /api/filters

Body: FilterDTO. Response: 200 {"ok": true}.

DELETE /api/filters/{id}

Response: 200 {"ok": true}.


WebSocket

A single upgrade route, not a REST endpoint:

GET /api/ws

Auth: cookie OR ?key=<api-key> query param. Origin pinned to Host.

The server pushes Envelope-shaped JSON frames at ~1 Hz; clients send nothing (reads are drained for liveness only).

See WebSocket for the full frame catalog.