Files
OwnCord/docs/api.md
T
Claude 071426c0d8 feat(server,client): announcement channels (D1, closes A-2026-07-01)
Make 'announcement' a real channel type, resolving the contradiction where
it was documented and offered by the admin API but hard-rejected by the
migration-013 DB triggers.

Model: announcement channels are readable like text channels (same
READ_MESSAGES visibility), but posting is restricted to users with
MANAGE_MESSAGES — no new permission bit, migration, or client permission
plumbing needed.

Server:
- migrations/016: recreate the channel-type triggers to allow
  text/voice/announcement/dm.
- service/message.go: checkSendPermission now takes the channel type and
  rejects posts to announcement channels from users lacking MANAGE_MESSAGES
  (SendMessage + CanPost paths). Added a service test.
- Unread counts: ready-payload builder (ws/serve.go) and
  GetChannelUnreadCounts (db) now include announcement channels alongside
  text, so they track unread/last-message like text channels.

Client:
- ChannelSidebar renders announcement channels with a megaphone icon
  (added to the icon set) instead of the '#' text prefix; they otherwise
  behave like text channels (already typed in ChannelType).

Specs + trackers (api.md, protocol.md, schema.md incl. migration 016,
architecture/data-model.md, audit A-2026-07-01, decisions D1) updated.

Verified: go build ./...; go test ./service ./db ./ws ./api ./admin;
sqlc-verify; client tsc + oxlint + prettier clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UA17KPvqGBX3XbXYnMf1rA
2026-07-19 16:00:18 +00:00

1062 lines
22 KiB
Markdown

# REST API Reference
OwnCord server REST API reference. All endpoints use the base URL `https://{server}:{port}/api/v1`.
---
## Authentication
All authenticated endpoints require a session token delivered via the `Authorization: Bearer {token}` header. Tokens are obtained from `POST /api/v1/auth/login`, `POST /api/v1/auth/register`, or `POST /api/v1/auth/verify-totp` after a partial 2FA challenge.
### Session Lifecycle
- Sessions are created on login/register and stored with a SHA-256 hash of the raw token, the client IP, User-Agent, and an expiry timestamp.
- Each authenticated request updates the session's `last_active` timestamp.
- Banned users are rejected at the middleware level with `403 FORBIDDEN`.
### Middleware Stack (all routes)
1. **RequestID** -- assigns a unique `X-Request-Id` response header.
2. **Recoverer** -- catches panics and returns 500.
3. **Request Logger** -- structured logging of method, path, status, duration.
4. **SecurityHeaders** -- sets `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `X-XSS-Protection: 0`, `Referrer-Policy: strict-origin-when-cross-origin`, `Content-Security-Policy: default-src 'self'`, `Permissions-Policy: camera=(), microphone=(), geolocation=()`, `Cache-Control: no-store`.
5. **MaxBodySize** -- 1 MiB default for all routes except `/api/v1/uploads` (which has its own 100 MiB limit).
---
## Standard Error Response
All error responses use this JSON envelope:
```json
{
"error": "ERROR_CODE",
"message": "Human-readable detail"
}
```
### Error Codes
| Code | HTTP Status | When It Occurs |
| ---- | ----------- | -------------- |
| `UNAUTHORIZED` | 401 | Missing/invalid/expired session token |
| `INVALID_CREDENTIALS` | 401 | Login/register with bad username/password/invite (generic to prevent enumeration) |
| `FORBIDDEN` | 403 | Insufficient permissions, banned account, or admin IP restriction |
| `NOT_FOUND` | 404 | Resource (channel, message, user, invite, file, backup) not found |
| `RATE_LIMITED` | 429 | Too many requests; response includes `Retry-After` header (seconds) |
| `INVALID_INPUT` / `BAD_REQUEST` | 400 | Malformed body, missing required fields, invalid query params |
| `CONFLICT` | 409 | Duplicate username on register, or server already up-to-date on update |
| `TOO_LARGE` | 413 | File exceeds upload size limit |
| `SERVER_ERROR` / `INTERNAL` | 500 | Internal server error |
| `BAD_GATEWAY` | 502 | Upstream failure (GitHub API, LiveKit, asset download) |
---
## Auth Endpoints
### POST /api/v1/auth/register
Create a new account using an invite code. The first user is created via `/admin/api/setup` instead.
**Auth:** None (public)
**Rate limit:** 3 requests/minute per IP
#### Request
```json
{
"username": "alex",
"password": "MyStr0ng!Pass",
"invite_code": "abc123def"
}
```
| Field | Type | Required | Notes |
| ----- | ---- | -------- | ----- |
| `username` | string | Yes | HTML-stripped, trimmed. Must be non-empty. |
| `password` | string | Yes | Validated for strength (min length, complexity). |
| `invite_code` | string | Yes | Must be a valid, non-expired, non-revoked invite with remaining uses. |
#### Response 201 Created
```json
{
"token": "raw-session-token-64-chars",
"user": {
"id": 2,
"username": "alex",
"avatar": "",
"status": "offline",
"role_id": 4,
"totp_enabled": false,
"created_at": "2026-03-24T12:00:00Z"
}
}
```
#### Errors
| Status | Code | Cause |
| ------ | ---- | ----- |
| 400 | `INVALID_INPUT` | Missing username/password/invite_code, or weak password |
| 400 | `INVALID_CREDENTIALS` | Bad invite code, expired/revoked invite, or duplicate username |
| 403 | `FORBIDDEN` | Registration is closed or unavailable while server-wide 2FA is required |
| 429 | `RATE_LIMITED` | Exceeded 3 registrations/minute from this IP |
| 500 | `SERVER_ERROR` | Hashing failure, session creation failure, or DB error |
---
### POST /api/v1/auth/login
Authenticate with username and password.
**Auth:** None (public)
**Rate limit:** 60 requests/minute per IP. After 10 consecutive failures from the same IP, the IP is locked out for 15 minutes.
#### Request
```json
{
"username": "alex",
"password": "MyStr0ng!Pass"
}
```
#### Response 200 OK
If the account does not have TOTP enabled:
```json
{
"token": "raw-session-token-64-chars",
"requires_2fa": false,
"user": {
"id": 1,
"username": "alex",
"avatar": "uuid.png",
"status": "offline",
"role_id": 4,
"totp_enabled": false,
"created_at": "2026-03-24T12:00:00Z"
}
}
```
If the account has TOTP enabled:
```json
{
"partial_token": "opaque-partial-token",
"requires_2fa": true
}
```
#### Errors
| Status | Code | Cause |
| ------ | ---- | ----- |
| 400 | `INVALID_INPUT` | Missing username or password |
| 401 | `UNAUTHORIZED` | Wrong username or password |
| 403 | `FORBIDDEN` | Account is banned/suspended |
| 429 | `RATE_LIMITED` | IP locked out after 10 consecutive failures (15 min cooldown) |
| 500 | `SERVER_ERROR` | Session creation failure |
---
### POST /api/v1/auth/verify-totp
Complete a TOTP login challenge started by `POST /api/v1/auth/login`.
**Auth:** Required with the `partial_token` from the login response
**Rate limit:** 10 requests/minute per IP, plus a 5-attempt budget per partial challenge
#### Request
```json
{
"code": "123456"
}
```
#### Response 200 OK
```json
{
"token": "raw-session-token-64-chars",
"requires_2fa": false,
"user": {
"id": 1,
"username": "alex",
"avatar": "uuid.png",
"status": "offline",
"role_id": 4,
"totp_enabled": true,
"created_at": "2026-03-24T12:00:00Z"
}
}
```
#### Errors
| Status | Code | Cause |
| ------ | ---- | ----- |
| 400 | `INVALID_INPUT` | Malformed request body |
| 401 | `UNAUTHORIZED` | Missing/expired challenge, invalid TOTP code, or challenge consumed |
| 500 | `SERVER_ERROR` | Session creation failure |
---
### GET /api/v1/auth/me
Get the current authenticated user's profile.
**Auth:** Required (Bearer token)
#### Response 200 OK
```json
{
"id": 1,
"username": "alex",
"avatar": "uuid.png",
"status": "online",
"role_id": 2,
"totp_enabled": true,
"created_at": "2026-03-24T12:00:00Z"
}
```
| Field | Type | Description |
| ----- | ---- | ----------- |
| `id` | int64 | User ID |
| `username` | string | Display name |
| `avatar` | string | Avatar filename (UUID) or empty string |
| `status` | string | One of: `online`, `idle`, `dnd`, `offline` |
| `role_id` | int64 | Numeric role ID (1=Owner, 2=Admin, 3=Moderator, 4=Member) |
| `totp_enabled` | bool | Whether the user has a confirmed TOTP secret |
| `created_at` | string | ISO 8601 timestamp |
---
### POST /api/v1/auth/logout
Invalidate the current session token.
**Auth:** Required (Bearer token)
#### Response 204 No Content
---
### DELETE /api/v1/auth/account
Permanently delete the authenticated user's account. Requires password confirmation.
**Auth:** Required (Bearer token)
**Rate limit:** 5 requests/minute per IP. After 3 failed password attempts, the endpoint locks out for 15 minutes per user.
#### Request
```json
{
"password": "MyStr0ng!Pass"
}
```
#### Response 204 No Content
Account deleted successfully. All sessions, messages (soft-deleted), and associated data are cleaned up.
#### Errors
| Status | Code | Cause |
| ------ | ---- | ----- |
| 400 | `INVALID_INPUT` | Missing or incorrect password |
| 403 | `FORBIDDEN` | Cannot delete the last admin account |
| 429 | `RATE_LIMITED` | Locked out after 3 failed password attempts (15 min cooldown) |
| 500 | `SERVER_ERROR` | Database error during deletion |
---
### POST /api/v1/users/me/totp/enable
Start TOTP enrollment for the authenticated user. The secret is not persisted until `/api/v1/users/me/totp/confirm` succeeds.
**Auth:** Required
**Rate limit:** 5 requests/minute per IP
#### Request
```json
{
"password": "MyStr0ng!Pass"
}
```
#### Response 200 OK
```json
{
"qr_uri": "otpauth://totp/OwnCord:alex?...",
"backup_codes": []
}
```
---
### POST /api/v1/users/me/totp/confirm
Confirm a pending TOTP enrollment.
**Auth:** Required
**Rate limit:** 5 requests/minute per IP
#### Request
```json
{
"password": "MyStr0ng!Pass",
"code": "123456"
}
```
#### Response 204 No Content
---
### DELETE /api/v1/users/me/totp
Disable TOTP for the authenticated user.
**Auth:** Required
**Rate limit:** 5 requests/minute per IP
#### Request
```json
{
"password": "MyStr0ng!Pass"
}
```
#### Response 204 No Content
---
## User Profile & Sessions
### PATCH /api/v1/users/me
Update the authenticated user's profile (username and/or avatar).
Broadcasts a `user_update` WebSocket message to all clients on success.
**Auth:** Required
**Rate limit:** 10 requests/minute
#### Request
```json
{
"username": "newname",
"avatar": "upload-uuid.png"
}
```
Both fields optional; `avatar` may be `null` to clear it.
#### Response 200 OK
Returns the updated user object (same shape as `GET /api/v1/auth/me`).
---
### PUT /api/v1/users/me/password
Change the authenticated user's password. Verifies the old password, enforces
password strength, and revokes all *other* sessions on success.
**Auth:** Required
**Rate limit:** 5 requests/minute, plus a failed-confirmation lockout on
repeated wrong old passwords
#### Request
```json
{
"old_password": "OldPass!1",
"new_password": "NewStr0ng!Pass"
}
```
#### Response 204 No Content
Password changed and other sessions revoked. If the password change committed
but revoking other sessions failed, the endpoint returns **200 OK** with a
warning body instead (the new password is in effect — do not retry with the
old one).
#### Errors
| Status | Code | Cause |
| ------ | ---- | ----- |
| 400 | `INVALID_INPUT` | Weak new password, or new password equals old |
| 403 | `FORBIDDEN` | Incorrect old password |
| 429 | `RATE_LIMITED` | Too many attempts / lockout |
---
### GET /api/v1/users/me/sessions
List the authenticated user's active sessions.
**Auth:** Required
#### Response 200 OK
```json
{
"sessions": [
{
"id": 12,
"device": "Mozilla/5.0 ...",
"ip": "192.168.1.100",
"created_at": "2026-07-01T10:00:00Z",
"last_used": "2026-07-19T09:00:00Z",
"is_current": true
}
]
}
```
---
### DELETE /api/v1/users/me/sessions/{id}
Revoke one of the authenticated user's sessions by ID.
**Auth:** Required
#### Response 204 No Content
---
## Channel Endpoints
### GET /api/v1/channels
List all channels the authenticated user has `READ_MESSAGES` permission for. DM channels are NOT included (use `GET /api/v1/dms` instead).
**Auth:** Required
#### Response 200 OK
```json
[
{
"id": 1,
"name": "general",
"type": "text",
"topic": "Welcome to the server!",
"category": "Text Channels",
"position": 0,
"slow_mode": 0,
"archived": false
}
]
```
| Field | Type | Description |
| ----- | ---- | ----------- |
| `id` | int64 | Channel ID |
| `name` | string | Channel name |
| `type` | string | `text`, `voice`, or `announcement` (announcement channels are read like text but only `MANAGE_MESSAGES` holders can post) |
| `topic` | string | Channel topic/description |
| `category` | string | Category grouping |
| `position` | int | Sort order within category |
| `slow_mode` | int | Slow-mode delay in seconds (0 = disabled) |
| `archived` | bool | Whether the channel is archived |
---
### GET /api/v1/channels/{id}/messages
Paginated message history for a channel.
**Auth:** Required
**Permission:** `READ_MESSAGES` on the channel (or DM participant membership)
#### Query Parameters
| Param | Type | Default | Range | Description |
| ----- | ---- | ------- | ----- | ----------- |
| `before` | int64 | 0 (latest) | >= 0 | Cursor: return messages with ID less than this value |
| `limit` | int | 50 | 1-100 | Number of messages to return |
#### Response 200 OK
```json
{
"messages": [
{
"id": 1042,
"channel_id": 5,
"user": {
"id": 1,
"username": "alex",
"avatar": "uuid.png"
},
"content": "Hello!",
"reply_to": null,
"attachments": [
{
"id": "file-uuid",
"filename": "photo.jpg",
"size": 204800,
"mime_type": "image/jpeg",
"url": "/api/v1/files/file-uuid",
"width": 1920,
"height": 1080
}
],
"reactions": [
{
"emoji": "\ud83d\udc4d",
"count": 2,
"me": true
}
],
"pinned": false,
"edited_at": null,
"deleted": false,
"timestamp": "2026-03-14T10:30:00Z"
}
],
"has_more": true
}
```
#### Pagination
Use cursor-based pagination by passing the `id` of the last message as the `before` parameter:
```
GET /api/v1/channels/5/messages?before=1042&limit=50
```
When `has_more` is `false`, you have reached the beginning of the channel history.
---
### GET /api/v1/channels/{id}/pins
Get all pinned messages for a channel.
**Auth:** Required
**Permission:** `READ_MESSAGES` on the channel
#### Response 200 OK
Returns `{ "messages": [...], "has_more": false }`. `has_more` is always `false` for pins (all pinned messages are returned at once).
---
### POST /api/v1/channels/{id}/pins/{messageId}
Pin a message in a channel.
**Auth:** Required
**Permission:** `MANAGE_MESSAGES` on the channel
#### Response 204 No Content
---
### DELETE /api/v1/channels/{id}/pins/{messageId}
Unpin a message from a channel.
**Auth:** Required
**Permission:** `MANAGE_MESSAGES` on the channel
#### Response 204 No Content
---
## Search
### GET /api/v1/search
Full-text search across messages in channels the user can read. Uses SQLite FTS5 for matching.
**Auth:** Required
**Rate limit:** 30 requests/minute
#### Query Parameters
| Param | Type | Default | Range | Description |
| ----- | ---- | ------- | ----- | ----------- |
| `q` | string | (required) | non-empty | Search query (FTS5 syntax) |
| `channel_id` | int64 | (all channels) | > 0 | Restrict search to a single channel |
| `limit` | int | 50 | 1-100 | Maximum results to return |
#### Response 200 OK
```json
{
"results": [
{
"message_id": 1042,
"channel_id": 5,
"channel_name": "general",
"user": {
"id": 1,
"username": "alex"
},
"content": "...matched text...",
"timestamp": "2026-03-14T10:30:00Z"
}
]
}
```
---
## Direct Messages
DM channels use participant-based authorization rather than role-based permissions.
### POST /api/v1/dms
Create or retrieve a 1-on-1 DM channel with another user. If a DM channel already exists, it is returned and re-opened.
**Auth:** Required
#### Request
```json
{
"recipient_id": 2
}
```
#### Response 200 OK (existing channel) or 201 Created (new channel)
```json
{
"channel_id": 100,
"recipient": {
"id": 2,
"username": "jordan",
"avatar": "uuid.png",
"status": "online"
},
"created": false
}
```
---
### GET /api/v1/dms
List all open DM channels for the authenticated user, ordered by most recent activity.
**Auth:** Required
#### Response 200 OK
```json
{
"dm_channels": [
{
"channel_id": 100,
"recipient": {
"id": 2,
"username": "jordan",
"avatar": "uuid.png",
"status": "online"
},
"last_message_id": 5042,
"last_message": "Hey, how's it going?",
"last_message_at": "2026-03-28T14:30:00Z",
"unread_count": 3
}
]
}
```
---
### DELETE /api/v1/dms/{channelId}
Close a DM channel for the authenticated user (hides it from their sidebar). The channel and messages remain in the database. If the other user sends a new message, the channel is automatically re-opened.
**Auth:** Required
#### Response 204 No Content
---
## User Blocks
Blocking a user prevents DM creation and messaging in both directions
(backed by the `user_blocks` table).
### GET /api/v1/blocks
List the IDs of users the authenticated user has blocked.
**Auth:** Required
#### Response 200 OK
```json
{ "blocked_user_ids": [2, 7] }
```
---
### PUT /api/v1/blocks/{userId}
Block a user.
**Auth:** Required
#### Response 200 OK
```json
{ "message": "user blocked" }
```
---
### DELETE /api/v1/blocks/{userId}
Unblock a user.
**Auth:** Required
---
## Invite Endpoints
All invite endpoints require authentication and the `MANAGE_INVITES` permission.
### POST /api/v1/invites
Create a new invite code.
**Auth:** Required
**Permission:** `MANAGE_INVITES`
#### Request
```json
{
"max_uses": 5,
"expires_in_hours": 48
}
```
Both fields are optional. An empty body creates an invite with unlimited uses and no expiry.
#### Response 201 Created
```json
{
"id": 1,
"code": "abc123def",
"max_uses": 5,
"uses": 0,
"expires_at": "2026-03-30T10:30:00Z",
"revoked": false,
"created_at": "2026-03-28T10:30:00Z"
}
```
---
### GET /api/v1/invites
List all invites (active, expired, and revoked).
**Auth:** Required
**Permission:** `MANAGE_INVITES`
#### Response 200 OK
Returns a JSON array of invite objects.
---
### DELETE /api/v1/invites/{code}
Revoke an invite by its code string.
**Auth:** Required
**Permission:** `MANAGE_INVITES`
#### Response 204 No Content
---
## File Upload and Serving
### POST /api/v1/uploads
Upload a file as multipart form data.
**Auth:** Required
**Rate limit:** 10 requests/minute
**Body size limit:** 100 MiB
**Content-Type:** `multipart/form-data`
Files are validated against blocked magic bytes (PE executables, ELF binaries, Mach-O binaries, shell scripts). Files are stored with UUID filenames.
#### Response 201 Created
```json
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"filename": "photo.jpg",
"size": 204800,
"mime": "image/jpeg",
"url": "/api/v1/files/a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"width": 1920,
"height": 1080
}
```
`width` and `height` are only present for image files.
---
### GET /api/v1/files/{id}
Serve a previously uploaded file by its UUID.
**Auth:** Required (Bearer token) — downloads are access-controlled
**Caching:** `Cache-Control: private, no-cache` (never stored by shared/proxy caches; browsers must revalidate)
Supports HTTP range requests and conditional requests. MIME types that could
execute under the app origin (HTML, SVG, XML, PDF) are served with
`Content-Disposition: attachment` to force download.
---
## Health Check
### GET /health
### GET /api/v1/health
Public health check endpoint, no authentication required. The server version
is deliberately not exposed here (anti-fingerprinting hardening, C-2).
```json
{
"status": "ok",
"uptime": 86400,
"online_users": 3
}
```
---
## Server Info
### GET /api/v1/info
Returns the server name. The version field was removed from this
unauthenticated endpoint (anti-fingerprinting hardening, C-2).
**Auth:** None
```json
{
"name": "My OwnCord Server"
}
```
---
## Metrics
### GET /api/v1/metrics
Runtime server metrics. Restricted to admin-allowed CIDRs.
**Auth:** Admin IP restriction (not token-based)
```json
{
"uptime": "2h30m15s",
"uptime_seconds": 9015.0,
"goroutines": 42,
"heap_alloc_mb": 12.5,
"heap_sys_mb": 24.0,
"num_gc": 156,
"connected_users": 8,
"livekit_healthy": true
}
```
---
## Plugin Administration
Manage WASM plugins. These endpoints sit behind **both** the admin IP
restriction (allowed CIDRs) **and** admin bearer-token authentication.
Plugin execution additionally requires a server built with `-tags wazero`
and `plugins.enabled: true` in config.
### GET /api/v1/admin/plugins
List installed plugins.
#### Response 200 OK
Array of plugin rows: `ID`, `Name`, `Version`, `Enabled`, `ManifestJSON`,
`InstalledAt`.
### POST /api/v1/admin/plugins/install
Install a plugin from an uploaded zip (multipart form). The archive is
size-capped (16 MiB compressed / 64 MiB uncompressed) and hardened against
zip-slip and symlinks; installation is staged and atomically renamed.
#### Response 201 Created
```json
{ "name": "plugin-name" }
```
### POST /api/v1/admin/plugins/{id}/enable
### POST /api/v1/admin/plugins/{id}/disable
Enable or disable an installed plugin.
### DELETE /api/v1/admin/plugins/{id}
Uninstall a plugin.
---
## LiveKit Endpoints
These endpoints are only registered when LiveKit voice is configured.
### POST /api/v1/livekit/webhook
LiveKit webhook receiver. Uses LiveKit JWT verification. Admin-IP-restricted. Called by the LiveKit server, not by clients.
### GET /api/v1/livekit/health
Check whether the LiveKit server is reachable.
**Auth:** Admin IP restriction
#### Response 200 OK
```json
{
"status": "ok",
"livekit_reachable": true
}
```
#### Response 503 Service Unavailable
```json
{
"status": "degraded",
"livekit_reachable": false,
"error": "connection refused"
}
```
### /livekit/* (Reverse Proxy)
All requests to `/livekit/*` are reverse-proxied to the LiveKit server URL. The `/livekit` prefix is stripped before forwarding. This allows the client to connect to LiveKit through OwnCord's HTTPS server, avoiding mixed-content blocks.
**Auth:** None (LiveKit handles its own JWT-based auth)
**Rate limit:** 30 requests/minute per IP
---
## Diagnostics
### GET /api/v1/diagnostics/connectivity
Returns connectivity diagnostics for debugging voice/network issues.
**Auth:** Required (any authenticated user)
**Rate limit:** 5 requests/minute per user
```json
{
"server": {
"version": "1.0.0",
"uptime_s": 3600,
"go_version": "go1.23.0",
"online_users": 5
},
"voice": {
"enabled": true,
"livekit_url": "ws://localhost:7880",
"livekit_health": true,
"node_ip": "203.0.113.1",
"proxy_path": "/livekit"
},
"client": {
"remote_addr": "192.168.1.100",
"is_private_network": true
}
}
```
---
## Client Auto-Update
### GET /api/v1/client-update/{target}/{current_version}
Tauri-compatible update endpoint. The desktop client checks this to see if a newer version is available.
**Auth:** None
#### Path Parameters
| Param | Type | Description |
| ----- | ---- | ----------- |
| `target` | string | Platform target (e.g., `windows-x86_64`) |
| `current_version` | string | Client's current semver version (e.g., `1.0.0`) |
#### Response 200 OK (update available)
```json
{
"version": "1.2.0",
"notes": "## What's Changed\n...",
"pub_date": "2026-03-28T00:00:00Z",
"platforms": {
"windows-x86_64": {
"signature": "base64-encoded-signature",
"url": "https://github.com/J3vb/OwnCord/releases/download/v1.2.0/OwnCord_1.2.0_x64-setup.nsis.zip"
}
}
}
```
#### Response 204 No Content
Client is already up-to-date.
---
## WebSocket
### GET /api/v1/ws
WebSocket upgrade endpoint. Authentication is performed in-band (first message must be an `auth` frame with the session token). See [protocol.md](protocol.md) for the full WebSocket message protocol.