diff --git a/README.md b/README.md
index b823835..0ebe770 100644
--- a/README.md
+++ b/README.md
@@ -6,201 +6,247 @@
---
# Elephant
-Elephant – a privacy-first, open-source messaging application made with Go and Flutter. Elephant supports secure authentication, user discovery, and real-time chat over WebSocket, with all communication TLS-protected between client and server.
+Elephant is a privacy-first, open-source messaging application built with Go and Flutter. It offers end-to-end encrypted direct messages based on the Signal Protocol, group chats, and real-time delivery over WebSocket. It also supports password and Google/GitHub sign-in, and keeps an encrypted on-device message store.
- ## Tech Stack
-
+## Features
+
+- **End-to-end encrypted direct messages:** The Signal Protocol (X3DH + Double Ratchet, via `libsignal_protocol_dart`) runs on the device. The server only stores public prekey bundles and opaque ciphertext.
+- **Contact verification:** Users can mark a contact's identity key as verified. The verification resets automatically if that contact's identity key changes.
+- **Group chats:** Groups have admin/member roles, renaming, member management, unread tracking, and typing indicators and read receipts. *Group messages are not yet end-to-end encrypted.*
+- **Real-time messaging:** Chat, typing indicators, read receipts and online presence are delivered over a single WebSocket.
+- **Message features:** Users can reply/quote messages and edit their own messages within 15 minutes. Paginated history is available for direct and group chats.
+- **Authentication:** Accounts use a username and password (Argon2id) or Google/GitHub OAuth. Short-lived JWT access tokens are paired with rotating refresh tokens.
+- **Offline-first client:** The app keeps a local SQLCipher-encrypted database and queues outgoing messages while offline, then replays them on reconnect.
+- **Self-hostable:** The app can point at any Elephant server, which is set from the login screen.
+
+## Tech Stack
+
| Layer | Technology |
|---|---|
-| Backend | Go (chi, pgx, gorilla/websocket, golang-jwt, argon2) |
-| Mobile | Flutter (http, web_socket_channel, provider, sqflite) |
+| Backend | Go 1.26 (chi, pgx, gorilla/websocket, golang-jwt, argon2id, golang.org/x/oauth2) |
+| Mobile | Flutter (dio, web_socket_channel, provider, sqflite_sqlcipher, flutter_secure_storage, libsignal_protocol_dart, app_links) |
| Database | PostgreSQL 16 |
-| CI/CD | GitHub Actions, Docker Compose, GHCR |
+| CI/CD | GitHub Actions, Docker Compose, GHCR, Docker Hub |
## GitHub Releases (Mobile Apps)
-If you just want to run the client application or get a production-ready backend binary without setting up a build environment, head straight over to our **[GitHub Releases Page](https://github.com/commandlinecoding/elephant/releases)**.
+If you only want to install the app, go to the **[GitHub Releases Page](https://github.com/commandlinecoding/elephant/releases)**.
+
+Each release includes Android APKs: a universal build plus per-ABI builds (`arm64-v8a`, `armeabi-v7a`, `x86_64`).
+
+By default, the app connects to the public server at `elephant.commandlinecoding.in`. To use your own server, tap the server indicator on the login screen and enter its host and port.
-Here you will find:
-* **Client Installers:** Pre-built application packages (`.apk` for Android ) ready for deployment.
---
-## Quick Start (Docker/Podman)
+## Quick Start (Docker Compose, recommended)
+
+[compose.yaml](compose.yaml) starts PostgreSQL, applies all database migrations, then starts the server.
+
+```bash
+git clone https://github.com/commandlinecoding/elephant.git
+cd elephant
+
+# Copy and fill in environment variables
+cp .env.example .env
+
+docker compose up -d
+# or: podman-compose up -d
+```
+
+The `server` service is built from [server/Dockerfile](server/Dockerfile), so local changes take effect after `docker compose up -d --build`.
-The fastest way to run Elephant is by pulling the published image — no Go toolchain required.
+Once it's running, verify the server is healthy:
-#### Pulling image from Docker Hub
+```bash
+curl http://HOST:PORT/api/health
+```
+
+You should see:
+
+```json
+{"success":true,"data":{"postgres":"up","timestamp":"..."}}
+```
+
+## Running the Published Image
+
+Prebuilt server images are published on every version tag. The image runs **only** the server binary, so you must provide a PostgreSQL 16 database and apply the migrations yourself (see [Database Migrations](#database-migrations)).
+
+#### Docker Hub
```bash
docker run --name elephant-server \
-e PORT=3000 \
-e POSTGRES_HOST=host.docker.internal \
- -e POSTGRES_PORT=3000 \
+ -e POSTGRES_PORT=5432 \
-e POSTGRES_USER=your_db_user \
-e POSTGRES_PASSWORD=your_db_password \
-e POSTGRES_DB=your_db_name \
- -e JWT_SECRET=a_secure_32_character_secret_key_phrase \
+ -e JWT_SECRET=a_secure_random_secret_of_at_least_32_chars \
-p 3000:3000 \
-d commandlinecoding/elephant:latest
- ```
+```
-#### Pulling image from Github GHCR
+#### GitHub Container Registry
```bash
podman run --name elephant-server \
-e PORT=3000 \
-e POSTGRES_HOST=host.containers.internal \
- -e POSTGRES_PORT=3000 \
+ -e POSTGRES_PORT=5432 \
-e POSTGRES_USER=your_db_user \
-e POSTGRES_PASSWORD=your_db_password \
-e POSTGRES_DB=your_db_name \
- -e JWT_SECRET=a_secure_32_character_secret_key_phrase \
+ -e JWT_SECRET=a_secure_random_secret_of_at_least_32_chars \
-p 3000:3000 \
-d ghcr.io/commandlinecoding/elephant:latest
```
+To enable Google/GitHub sign-in, also pass the `GOOGLE_*` / `GITHUB_*` variables listed in [Environment Variables](#environment-variables).
-Once running, verify the server is healthy:
-
-```bash
-curl http://HOST:PORT/api/health
-```
-
-You should see:
-
-```json
-{"success":true,"data":{"postgres":"up","timestamp":"..."}}
-```
-
-## Local Development Setup
-
-If you're contributing to the backend and want to build from source instead of pulling the published image, use the dev compose override:
+> The server speaks plain HTTP/WS. In production, put it behind a TLS-terminating reverse proxy (e.g. Caddy or nginx) that forwards WebSocket upgrades on `/api/ws`.
+## Local Development
-```bash
-# Clone the repo for the compose file and .env template
-git clone https://github.com/commandlinecoding/elephant.git
-cd elephant
-
-# Copy and fill in environment variables
-cp .env.example .env
-
-# Start the stack
-docker compose up -d
-# or: podman-compose up -d
-```
-
-This builds the `server` image from `server/Dockerfile` on every run instead of pulling from GHCR, so your local changes are reflected immediately.
-
### Running the backend without Docker
-
+
```bash
cd server
go mod download
go run main.go
```
-
-Make sure PostgreSQL is running and reachable using the values in your `.env` file (`POSTGRES_HOST`, `POSTGRES_PORT`, `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB`).
-
+
+The server looks for a `.env` file in the current directory and every parent directory, so the repo-root `.env` is picked up automatically. Make sure PostgreSQL is reachable with the `POSTGRES_*` values and that migrations have been applied.
+
+### Database Migrations
+
+SQL migrations live in [server/migrations/](server/migrations/) as numbered `NNN_*_up.sql` files. [server/migrate.sh](server/migrate.sh) applies them in order and records each one in a `schema_migrations` table, so re-running it is safe. Docker Compose runs it automatically through the `migration` service.
+
+To run it against your own database:
+
+```bash
+docker run --rm \
+ -v ./server/migrations:/migrations:z \
+ -v ./server/migrate.sh:/migrate.sh:z \
+ -e POSTGRES_HOST= -e POSTGRES_PORT=5432 \
+ -e POSTGRES_USER= -e POSTGRES_PASSWORD= -e POSTGRES_DB= \
+ postgres:16-alpine sh /migrate.sh
+```
+
### Running the mobile app
-
+
```bash
cd mobile
flutter pub get
flutter run
```
+See [mobile/README.md](mobile/README.md) for details on pointing the app at a local server and on how the client is structured.
+
## Environment Variables
-
+
| Variable | Description | Default |
|---|---|---|
-| `HOST` | Server bind address | `127.0.0.1` |
+| `HOST` | Host shown in the startup log. The server binds to `0.0.0.0` when this is `localhost`, `127.0.0.1` or empty | `127.0.0.1` |
| `PORT` | Server port | `3000` |
| `POSTGRES_USER` | Database username | `postgres` |
-| `POSTGRES_PASSWORD` | Database password | — |
+| `POSTGRES_PASSWORD` | Database password | `very_strong_password` |
| `POSTGRES_DB` | Database name | `myapi_db` |
| `POSTGRES_PORT` | Database port | `5432` |
| `POSTGRES_HOST` | Database host | `localhost` |
-| `JWT_SECRET` | Secret key for signing JWTs | — |
-| `ARGON_MEMORY` | Argon2id memory cost (KB) | `65536` |
+| `JWT_SECRET` | Secret key for signing JWTs (HS256). **Always set this**, because the built-in default is public | `default-long-safe-secret-key-string` |
+| `ARGON_MEMORY` | Argon2id memory cost (KiB) | `65536` |
| `ARGON_ITERATIONS` | Argon2id iteration count | `3` |
| `ARGON_PARALLELISM` | Argon2id parallelism degree | `2` |
-|`GOOGLE_CLIENT_ID`| Google OAuth client ID| — |
-| `GOOGLE_CLIENT_SECRET` | Google OAuth client secrett | — |
-| `GOOGLE_REDIRECT_URL` | Google OAuth redirect callback URL | `http://HOST:PORT/api/auth/google/callback` |
+| `GOOGLE_CLIENT_ID` | Google OAuth client ID | — |
+| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | — |
+| `GOOGLE_REDIRECT_URL` | Google OAuth callback URL, e.g. `https://HOST/api/auth/google/callback` | — |
| `GITHUB_CLIENT_ID` | GitHub OAuth client ID | — |
| `GITHUB_CLIENT_SECRET` | GitHub OAuth client secret | — |
-| `GITHUB_REDIRECT_URL` | GitHub OAuth redirect callback URL | `http://HOST:PORT/api/auth/github/callback` |
-
-
-See `.env.example` for a starting template.
+| `GITHUB_REDIRECT_URL` | GitHub OAuth callback URL, e.g. `https://HOST/api/auth/github/callback` | — |
+See [.env.example](.env.example) for a starting template. In Docker Compose, `POSTGRES_PORT` sets the **host** port that the database is published on. The server always connects to `db:5432` inside the network.
## API
-Checkout the [API document](docs/API_CONTRACTS.md)
+Check out the [API documentation](docs/API_CONTRACTS.md) for every REST endpoint, the WebSocket event protocol, and the E2EE key-distribution endpoints.
### Registration note
-
-Usernames are normalized to lowercase and assigned a random 4-digit discriminator on registration to avoid collisions, e.g. registering as `abc` may result in the stored username `abc.4821`. Use the full discriminated username returned in the registration response when logging in.
+
+Usernames are normalized to lowercase and always given a random 4-digit discriminator, e.g. registering as `abc` results in a username such as `abc.4821`. Log in with the full username returned in the registration response. OAuth accounts get a username derived from the email handle in the same format.
## Testing
-
-Run the full backend test suite (unit + integration):
-
+
+Run the backend checks and test suite:
+
```bash
cd server
go vet ./...
go test -v ./...
```
-
-Integration tests under [server/tests/](server/tests/) expect a running server and database. Start the stack first, then run:
-
+
+Some tests need a live database and server: the repository tests, the controller tests, and the integration tests under [server/tests/](server/tests/). Start the stack first (and apply migrations), then run:
+
```bash
HOST=$HOST PORT=$PORT go test -v ./tests/...
```
+For the mobile app:
+
+```bash
+cd mobile
+flutter analyze
+```
+
### CI/CD
-
-Docker images are built and published to both GHCR and Docker Hub automatically on every version tag push.
-Android APKs are built and attached to GitHub Releases automatically on every version tag push.
+
+| Workflow | Trigger | What it does |
+|---|---|---|
+| [backend.yaml](.github/workflows/backend.yaml) | Every push and PR | `go vet`, staticcheck, then starts PostgreSQL + server and runs `go test ./...` |
+| [release.yaml](.github/workflows/release.yaml) | `v*` tag | Builds and pushes the server image to GHCR |
+| [docker-release.yaml](.github/workflows/docker-release.yaml) | `v*` tag | Builds and pushes the server image to Docker Hub |
+| [android-release.yaml](.github/workflows/android-release.yaml) | `v*` tag | Runs `flutter analyze`, builds universal and split-ABI APKs, and attaches them to the GitHub Release |
## Project Structure
-
+
```
elephant/
-├── .github/ # GitHub Actions workflows
-├── docs/ # Project documentation
-├── mobile/ # Flutter client
+├── .github/ # Issue templates and GitHub Actions workflows
+├── docs/
+│ └── API_CONTRACTS.md # REST, WebSocket and E2EE API reference
+├── metadata/ # F-Droid / store listing metadata
+├── mobile/ # Flutter client (see mobile/README.md)
│ ├── android/
│ ├── ios/
-│ ├── lib/
│ ├── assets/
-│ ├── pubspec.yaml
-│ └── README.md
-├── server/ # Go backend
-│ ├── config/ # DB, JWT, Argon2, CORS configuration
-│ ├── controllers/ # HTTP handlers
-│ ├── env/ # Environment variable loading
-│ ├── errors/ # Sentinel error definitions
-│ ├── middlewares/ # Auth, CORS, logging, rate limiting
-│ ├── migrations/ # SQL migration files
-│ ├── models/ # Data models
-│ ├── repository/ # Database access layer
-│ ├── routes/ # Route definitions
-│ ├── services/ # Business logic (auth, users, JWT, WebSocket)
-│ ├── tests/ # Integration tests
+│ ├── lib/
+│ └── pubspec.yaml
+├── server/ # Go backend
+│ ├── config/ # Router, DB pool, Argon2, OAuth configuration
+│ ├── controllers/ # HTTP and WebSocket handlers
+│ ├── env/ # Environment variable loading (.env lookup)
+│ ├── errors/ # Error definitions
+│ ├── middlewares/ # Auth, group membership/admin guards, CORS, logging, rate limiting
+│ ├── migrations/ # Numbered SQL migrations (*_up.sql)
+│ ├── models/ # Request/response and domain models
+│ ├── repository/ # Database access layer
+│ ├── routes/ # Route definitions (/auth, /users, /messages, /groups, /e2ee)
+│ ├── services/ # Business logic (auth, JWT, OAuth, messages, E2EE, WebSocket hub)
+│ ├── tests/ # Integration tests
+│ ├── migrate.sh # Migration runner used by Docker Compose
│ └── Dockerfile
-├── compose.yaml # Default: pulls published image
-├── compose.dev.yaml # Override: builds server from source
+├── compose.yaml # PostgreSQL + migrations + server (built from source)
├── .env.example # Environment variable template
+├── SECURITY.md
+├── CODE_OF_CONDUCT.md
└── LICENSE
```
## Contributing
-Checkout [CONTRIBUTING.md](CONTRIBUTING.md) for more information
+Contributions are welcome. Please open an issue using one of the [issue templates](.github/ISSUE_TEMPLATE/) before starting larger changes, and follow the [pull request template](.github/ISSUE_TEMPLATE/pull_request_template.md). Please also read the [Code of Conduct](CODE_OF_CONDUCT.md).
+
+To report a security issue, follow [SECURITY.md](SECURITY.md) instead of opening a public issue.
## License
-
-See [LICENSE](LICENSE) for details.
+
+Elephant is licensed under the AGPL-3.0. See [LICENSE](LICENSE) for details.
diff --git a/SECURITY.md b/SECURITY.md
index 6454b0a..523e56b 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -1,6 +1,17 @@
-Security Policy
-Reporting a Vulnerability
+# Security Policy
-If you discover a security vulnerability in this repository, please report it using [GitHub's built-in reporting tool](https://github.com/CommandLineCoding/Elephant/security/advisories). We appreciate your efforts to responsibly disclose your findings and will make every effort to acknowledge your report promptly.
+## Reporting a Vulnerability
-Thank you for helping to keep our project secure!
\ No newline at end of file
+If you discover a security vulnerability in this repository, please report it privately using [GitHub's built-in reporting tool](https://github.com/CommandLineCoding/Elephant/security/advisories). Please do not open a public issue. We appreciate responsible disclosure and will acknowledge your report as soon as possible.
+
+## Scope
+
+Reports are especially welcome for:
+
+- The Go server (`server/`): authentication, JWT/refresh-token handling, OAuth, group authorization and the WebSocket hub
+- The E2EE implementation: prekey distribution (`/api/e2ee/*`) and the client's Signal Protocol usage (`mobile/lib/services/signal_service.dart`)
+- Local data protection in the mobile app (SQLCipher database and secure storage)
+
+Known limitation: group messages are not yet end-to-end encrypted. See [docs/API_CONTRACTS.md](docs/API_CONTRACTS.md#message-content-format-e2ee).
+
+Thank you for helping to keep Elephant secure!
diff --git a/docs/API_CONTRACTS.md b/docs/API_CONTRACTS.md
index bb8f13f..9620e81 100644
--- a/docs/API_CONTRACTS.md
+++ b/docs/API_CONTRACTS.md
@@ -2,90 +2,129 @@
## Global Requirements & Configuration
-- **Base URL:** `http://HOST:PORT` (Local Sandbox)
+- **Base URL:** `http://HOST:PORT/api` (every route below is mounted under `/api`)
- **Default Content-Type:** `application/json`
-- **Authorization:** `Bearer `
-- **Timestamp Format:** `ISO8601 UTC`
+- **Authorization:** `Authorization: Bearer ` on every protected route
+- **Timestamp Format:** `RFC3339 / ISO8601` (e.g. `2026-10-04T12:34:56.789Z`)
+- **IDs:** All user, group and message IDs are UUIDs
+- **CORS:** `Access-Control-Allow-Origin: *`; allowed methods `GET, POST, PUT, DELETE, OPTIONS`
+
+### Response envelope
+
+Every JSON response uses the same envelope. `data` is omitted on errors, and `error` is omitted on success.
+
+```json
+{
+ "success": true,
+ "data": { },
+ "error": ""
+}
+```
+
+### Tokens
+
+| Token | Lifetime | Notes |
+|---|---|---|
+| Access token | 15 minutes | HS256 JWT, `sub` = user ID. Send it as a Bearer token, or as `?token=` for the WebSocket |
+| Refresh token | 7 days | HS256 JWT. Its hash is stored server-side. Each refresh **rotates** it, so the old refresh token stops working |
+
+### Common error: `401 Unauthorized`
+
+Every route behind the auth guard returns this when the access token is missing, expired or malformed. The sections below don't repeat it.
+
+```json
+{
+ "success": false,
+ "error": "Access token missing, expired or malformed"
+}
+```
+
---
## API Overview
-| Method | Endpoint | Description |
-|---|---|---|
-| GET | [/api/health](#health-endpoint)| Health check (server + database status) |
-| POST | [/api/auth/register](#1-register-user) | Register a new user |
-| POST | [/api/auth/login](#2-login-user) | Log in and receive a token pair |
-| POST | [/api/auth/refresh](#3-refresh) | Exchange a refresh token for a new token pair |
-| GET | [/api/users/me](#1-user-me) | Get the authenticated user's profile |
-| GET | [/api/users/:id](#2-search-user-by-id) | Search users by User id |
-| GET | [/api/users/search?q=&page=&limit=](#3-search-user-by-username-and-display-name) | Search user by username and display name |
-| GET | [/ws](#1-connect-to-websocket) | WebSocket upgrade endpoint (JWT required) |
-| WS | [Client to Server](#2-client-to-server-messages) | Client to Server Websocket |
-| WS | [Server to Client](#3-server-to-client-messages) | Server to Client Websocket |
-| POST | [/api/messages](#1-send-message) | Send a message (REST fallback) |
-| GET | [/api/messages?with=&before=&limit=](#2-message-history) | Paginated message history |
-| GET | [/api/messages/conversations](#3-conversations) | Get user conversatiosn |
-| POST | [/api/messages/read](#4-read) | Get read mark |
-| POST | [/api/groups/](#1-create-group) | Create Group |
-| GET | [/api/groups/](#2-get-groups) | Get all groups of user |
-| GET | [/api/group/{id}](#3-get-group-by-group-id) | Get group by group id |
-| GET | [/api/groups/{id}/messages](#4-get-messages-of-group) | Get message of a group |
-| GET | [/api/groups/{id}/members](#5-group-members) | Get group members |
-| POST | [/api/groups/{id}/leave](#6-leave-group) | Leave a group |
-| PATCH | [/api/groups/{id}/](#7-update-group-details) | Update Group Details |
-| POST | [/api/groups/{id}/members](#8-add-member-to-group) | Add members to group |
-| DELETE | [/api/groups/{id}/members/{user_id}](#9-remove-user-from-group) | Remove member from group |
+| Method | Endpoint | Auth | Description |
+|---|---|---|---|
+| GET | [/api/health](#health-endpoint) | No | Health check (server + database status) |
+| POST | [/api/auth/register](#1-register-user) | No | Register a new user |
+| POST | [/api/auth/login](#2-login-user) | No | Log in and receive a token pair |
+| POST | [/api/auth/refresh](#3-refresh) | No | Rotate a refresh token for a new token pair |
+| GET | [/api/auth/{provider}](#4-oauth-redirect) | No | Start Google / GitHub OAuth sign-in |
+| GET | [/api/auth/{provider}/callback](#5-oauth-callback) | No | OAuth callback that hands tokens back to the app |
+| GET | [/api/users/me](#1-user-me) | Yes | Get the authenticated user's profile |
+| GET | [/api/users/{id}](#2-get-user-by-id) | Yes | Get a user by ID |
+| GET | [/api/users/search?q=&page=&limit=](#3-search-users) | Yes | Search users by username or display name (rate limited) |
+| GET | [/api/ws?token=](#1-connect-to-websocket) | Yes (query) | WebSocket upgrade endpoint |
+| WS | [Client → Server](#2-client-to-server-messages) | — | Events the client sends |
+| WS | [Server → Client](#3-server-to-client-messages) | — | Events the server pushes |
+| POST | [/api/messages](#1-send-message) | Yes | Send a direct message (REST fallback, no live delivery) |
+| GET | [/api/messages?with=&before=&limit=](#2-direct-message-history) | Yes | Paginated direct message history |
+| GET | [/api/messages/conversations](#3-conversations) | Yes | Inbox: latest message and unread count per chat |
+| PUT | [/api/messages/{id}](#4-edit-message) | Yes | Edit your own message (15-minute window) |
+| POST | [/api/messages/read](#5-mark-direct-messages-read) | Yes | Mark a sender's messages as read |
+| POST | [/api/groups](#1-create-group) | Yes | Create a group |
+| GET | [/api/groups](#2-list-my-groups) | Yes | List the groups you belong to |
+| GET | [/api/groups/{id}](#3-get-group-by-id) | Member | Get group details |
+| GET | [/api/groups/{id}/messages?before=&limit=](#4-group-message-history) | Member | Paginated group message history |
+| GET | [/api/groups/{id}/members](#5-group-members) | Member | List group members |
+| POST | [/api/groups/{id}/leave](#6-leave-group) | Member | Leave a group |
+| PATCH | [/api/groups/{id}](#7-update-group-details) | Admin | Rename a group |
+| POST | [/api/groups/{id}/members](#8-add-member-to-group) | Admin | Add a member |
+| DELETE | [/api/groups/{id}/members/{userId}](#9-remove-member-from-group) | Admin | Remove a member |
+| POST | [/api/e2ee/keys](#1-upload-prekey-bundle) | Yes | Upload identity key, signed prekey and one-time prekeys |
+| GET | [/api/e2ee/bundle/{userId}?device_id=](#2-fetch-prekey-bundle) | Yes | Fetch a user's prekey bundle (consumes one one-time prekey) |
+| POST | [/api/e2ee/verify](#3-set-contact-verification) | Yes | Mark a contact's identity key as verified or unverified |
+| GET | [/api/e2ee/verify/{userId}](#4-get-contact-verification) | Yes | Get your verification status for a contact |
+| POST | [/api/e2ee/reset](#5-reset-encryption-keys) | Yes | Wipe all of your server-side keys (password re-auth) |
+---
## Health Endpoint
-Evaluates the operational status of the server application and its upstream resource dependencies (such as the PostgreSQL database pool).
+Reports the status of the server and its PostgreSQL connection pool.
- **URL:** `/api/health`
- **Method:** `GET`
- **Authentication Required:** `NO`
-- **curl:** `curl -X GET http://HOST:PORT/api/health -s | jq .`
-
-### Expected Responses
+- **curl:** `curl -s http://HOST:PORT/api/health | jq .`
-#### `200 OK` (System operational)
-
-Returned when the server runtime is healthy and can successfully execute a ping query against the database.
+#### `200 OK`
```json
{
"success": true,
"data": {
"postgres": "up",
- "timestamp": ""
+ "timestamp": ""
}
}
```
#### `500 Internal Server Error`
-Returned when the server has encountered an unexpected error (error description provided)
+Returned when the database ping fails.
```json
{
"success": false,
"data": {
"postgres": "down",
- "timestamp": ""
+ "timestamp": ""
},
"error": "Database engine unreachable"
}
```
+---
+
## Authentication Endpoints
### 1. Register User
- **URL:** `/api/auth/register`
- **Method:** `POST`
-- **Headers:** `Content-Type: application/json`
- **Authentication Required:** `NO`
-- **curl:** `curl -X POST http://HOST:PORT/api/auth/register -H "Content-Type: application/json" -d '{ "username": "", "display_name": "Full Name", "password": "" }' -s`
+- **curl:** `curl -X POST http://HOST:PORT/api/auth/register -H "Content-Type: application/json" -d '{ "username": "", "display_name": "Full Name", "password": "" }' -s`
#### Request Body
@@ -97,15 +136,17 @@ Returned when the server has encountered an unexpected error (error description
}
```
-#### Note
+#### Validation rules
-The username in the response may differ from the one submitted. If the requested username is already taken, the server automatically appends a numeric suffix (e.g. "abc" → "abc.4130") to ensure uniqueness.
+- `username` is trimmed and lowercased. It must then be 3–25 characters and contain only `a-z` and `0-9`.
+- `password` must be at least 8 characters. It is hashed with Argon2id.
+- `display_name` must not be empty.
-#### Expected Responses
+#### Note
-##### `201 Created` (User Created Successfully)
+The server **always** appends a random 4-digit discriminator (1000–9999) to the requested username, e.g. `abc` → `abc.4130`. Use the full username returned in the response to log in.
-Returned when the account/user is successfully created
+#### `201 Created`
```json
{
@@ -117,30 +158,39 @@ Returned when the account/user is successfully created
},
"user": {
"id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
- "username": "",
+ "username": ".1234",
"display_name": "Full Name",
- "created_at": ""
+ "created_at": ""
}
}
}
```
-##### `400 Bad Request`
+#### `400 Bad Request`
-Returned when the json does not contain valid payload (error description provided)
+Returned for malformed JSON (`"Invalid payload syntax"`) or a failed validation rule. Examples:
```json
-{
- "success": false,
- "error": "Invalid payload syntax"
-}
+{ "success": false, "error": "username prefix must be between 3 and 25 characters" }
+```
+```json
+{ "success": false, "error": "username prefix must contain only alphanumeric characters" }
+```
+```json
+{ "success": false, "error": "password must be at least 8 characters long" }
+```
+```json
+{ "success": false, "error": "display name cannot be empty" }
```
+#### `500 Internal Server Error`
+
+`"Token generation failure"` or `"Failed to securely record registration token metrics"`.
+
### 2. Login User
- **URL:** `/api/auth/login`
- **Method:** `POST`
-- **Headers:** `Content-Type: application/json`
- **Authentication Required:** `NO`
- **curl:** `curl -X POST http://HOST:PORT/api/auth/login -H "Content-Type: application/json" -d '{ "username": "", "password": "" }' -s`
@@ -148,16 +198,14 @@ Returned when the json does not contain valid payload (error description provide
```json
{
- "username": "",
+ "username": ".1234",
"password": ""
}
```
-#### Expected Response
+The username is trimmed and lowercased before lookup.
-##### `200 OK` (Login Successful)
-
-Returned when User has successfully logged in
+#### `200 OK`
```json
{
@@ -169,7 +217,7 @@ Returned when User has successfully logged in
},
"user": {
"id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
- "username": "",
+ "username": ".1234",
"display_name": "Full Name",
"created_at": ""
}
@@ -177,9 +225,7 @@ Returned when User has successfully logged in
}
```
-##### `401 Unauthorized`
-
-Returned when credentials are incorrect
+#### `401 Unauthorized`
```json
{
@@ -188,9 +234,7 @@ Returned when credentials are incorrect
}
```
-##### `400 Bad Request`
-
-Returned when json payload structure is incorrect
+#### `400 Bad Request`
```json
{
@@ -201,9 +245,10 @@ Returned when json payload structure is incorrect
### 3. Refresh
+Exchanges a refresh token for a new access/refresh pair. The submitted refresh token is revoked.
+
- **URL:** `/api/auth/refresh`
- **Method:** `POST`
-- **Headers:** `Content-Type: application/json`
- **Authentication Required:** `NO`
- **curl:** `curl -X POST http://HOST:PORT/api/auth/refresh -H "Content-Type: application/json" -d '{ "refresh_token": "" }' -s`
@@ -215,11 +260,7 @@ Returned when json payload structure is incorrect
}
```
-#### Expected Response
-
-##### `200 OK` (Token Refresh Successful)
-
-Returned when token is refreshed successfully
+#### `200 OK`
```json
{
@@ -231,34 +272,84 @@ Returned when token is refreshed successfully
}
```
-##### `401 Unauthorized`
+#### `400 Bad Request`
-Returned when refresh token given is invalid
+`"Invalid json payload structure"` or `"Refresh token parameter missing"`.
+
+#### `401 Unauthorized`
+
+```json
+{ "success": false, "error": "invalid or altered refresh token payload" }
+```
+```json
+{ "success": false, "error": "refresh token expired or revoked" }
+```
+
+### 4. OAuth Redirect
+
+Starts the OAuth sign-in flow. The app opens this URL in a browser.
+
+- **URL:** `/api/auth/{provider}`, where `provider` is `google` or `github`
+- **Method:** `GET`
+- **Authentication Required:** `NO`
+
+#### `307 Temporary Redirect`
+
+Redirects to the provider's consent screen. It also sets an `oauth_state` cookie (HttpOnly, SameSite=Lax, 5 minutes) that the callback validates.
+
+#### `400 Bad Request`
```json
{
"success": false,
- "error": "refresh token expired or revoked"
+ "error": "Unsupported OAuth provider context"
}
```
-## Search Endpoints
+### 5. OAuth Callback
-### 1. User ME
+The provider redirects here. Set it as the redirect URL in your Google / GitHub OAuth app, and in `GOOGLE_REDIRECT_URL` / `GITHUB_REDIRECT_URL`.
+
+- **URL:** `/api/auth/{provider}/callback?code=&state=`
+- **Method:** `GET`
+- **Authentication Required:** `NO`
+
+#### Account resolution
+
+1. If a user is already linked to this provider account ID, sign them in.
+2. Otherwise, if a user exists with the same email, link the provider to that user.
+3. Otherwise, create a new user. The username is `.<4 digits>` and the display name comes from the provider. OAuth-only accounts have no usable password.
+
+GitHub sign-in requires a verified primary email on the GitHub account.
+
+#### `200 OK` (HTML)
-Get user data
+Returns an HTML handoff page that opens the app with the token pair:
+
+```
+elephant://oauth-callback?access_token=&refresh_token=
+```
+
+On Android, the page first tries an `intent://…#Intent;scheme=elephant;package=in.commandlinecoding.elephant;end;` URL, then falls back to the custom scheme.
+
+#### `401 Unauthorized` (HTML)
+
+An "Authentication Failed" HTML page is returned when the state cookie doesn't match, the provider exchange fails, or the account can't be created.
+
+---
+
+## User Endpoints
+
+`User` objects can also include `email`, `google_id` and `github_id`. These fields are omitted when empty, and the endpoints below never populate them.
+
+### 1. User ME
- **URL:** `/api/users/me`
- **Method:** `GET`
-- **Headers:** `Content-Type: application/json`
- **Authentication Required:** `YES`
-- **curl:** `curl -X GET "http://HOST:PORT/api/users/me" -H "Authorization: Bearer "`
-
-#### Expected Responses
-
-##### `200 OK`
+- **curl:** `curl "http://HOST:PORT/api/users/me" -H "Authorization: Bearer "`
-Returned when user data is successfully retrieved
+#### `200 OK`
```json
{
@@ -272,33 +363,23 @@ Returned when user data is successfully retrieved
}
```
-##### `401 Unauthorized`
-
-Returned when access token is missing, expired or wrong
+#### `404 Not Found`
```json
{
"success": false,
- "error": "Access token missing, expired or malformed"
+ "error": "User profile not found"
}
```
-### 2. Search user by ID
-
-Used to find user with user id
-Get user data
+### 2. Get User by ID
- **URL:** `/api/users/{id}`
- **Method:** `GET`
-- **Headers:** `Content-Type: application/json`
- **Authentication Required:** `YES`
-- **curl:** `curl -X GET "http://HOST:PORT/api/users/{id}" -H "Authorization: Bearer "`
-
-#### Expected Responses
+- **curl:** `curl "http://HOST:PORT/api/users/{id}" -H "Authorization: Bearer "`
-##### `200 OK`
-
-Returned when user is successfully found
+#### `200 OK`
```json
{
@@ -306,15 +387,13 @@ Returned when user is successfully found
"data": {
"id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
"username": "",
- "display_name": "Full name",
+ "display_name": "Full Name",
"created_at": ""
}
}
```
-##### `404 Not Found`
-
-Returned when user is not found or user id is incorrect
+#### `404 Not Found`
```json
{
@@ -323,33 +402,23 @@ Returned when user is not found or user id is incorrect
}
```
-##### `401 Unauthorized`
-
-Returned when access token is missing or expired or incorrect
-
-```json
-{
- "success": false,
- "error": "Access token missing, expired or malformed"
-}
-```
-
-### 3. Search User by username and display name
+### 3. Search Users
-Used to find user by username and display name
-Get user data
+Case-insensitive substring match on username **or** display name, ordered by username.
-- **URL:** `/api/users/search?q={username}&page={page}&limit={limit}`
+- **URL:** `/api/users/search?q={query}&page={page}&limit={limit}`
- **Method:** `GET`
-- **Headers:** `Content-Type: application/json`
- **Authentication Required:** `YES`
-- **curl:** `curl -X GET "http://HOST:PORT/api/users/search?q={name}&page={page}&limit={limit}" -H "Authorization: Bearer "`
+- **Rate limit:** Token bucket per client IP: 10-request burst, refilling at 2 requests/second
+- **curl:** `curl "http://HOST:PORT/api/users/search?q={query}&page=1&limit=20" -H "Authorization: Bearer "`
-#### Expected Responses
-
-##### `200 OK`
+| Param | Default | Notes |
+|---|---|---|
+| `q` | `""` | An empty query matches every user |
+| `page` | `1` | 1-based |
+| `limit` | `20` | Values outside 1–100 fall back to 20 |
-Returned when user is successfully found
+#### `200 OK`
```json
{
@@ -360,40 +429,23 @@ Returned when user is successfully found
"username": "",
"display_name": "Full Name",
"created_at": ""
- },
- {
- "id": "bbbbbbbb-bbbb-bbbb-bbbbbbbbbbbb",
- "username": "",
- "display_name": "Full Name",
- "created_at": ""
}
]
}
```
-`NOTE:` If no users are found
-
-```json
-{
- "success": true,
- "data": []
-}
-```
-
-##### `401 Unauthorized`
+If no users match, `data` is an empty array.
-Returned when access token is missing or expired or incorrect
+#### `429 Too Many Requests`
```json
{
"success": false,
- "error": "Access token missing, expired or malformed"
+ "error": "Rate limit exceeded. Too many search requests."
}
```
-##### `500 Internal Server Error`
-
-Returned when search has failed to execute
+#### `500 Internal Server Error`
```json
{
@@ -402,177 +454,174 @@ Returned when search has failed to execute
}
```
+---
+
## WebSocket Endpoints
-### 1. Connect to websocket
+### 1. Connect to WebSocket
- **URL:** `/api/ws?token={access_token}`
-- **Method:** `WS (websocket Upgrade)`
-- **Headers:** `NONE`
-- **Authentication Required:** `YES`
+- **Method:** `GET` (WebSocket upgrade)
+- **Authentication Required:** `YES` (access token in the `token` query parameter)
- **wscat:** `wscat -c "ws://HOST:PORT/api/ws?token={access_token}"`
-#### Expected Response
+#### `101 Switching Protocols`
-##### `101 Switching Protocols`
+The connection is open. All other connected users receive a `user_status` event with `online: true`.
-Returned when websocket is successfully connected
+#### `401 Unauthorized`
+
+Returned (with no body) when `token` is missing, expired or invalid.
```
-connected to ws://HOST:PORT/api/ws?token={access_token}
+error: Unexpected server response: 401
```
-##### `Unexpected server response: 404`
+#### Connection behaviour
-Returned when access token is expired or incorrect
+- The server sends a WebSocket ping every 54 seconds. A connection that hasn't sent a pong (or any message) within 60 seconds is closed.
+- The maximum inbound frame size is **4096 bytes**. Larger frames close the connection, so keep encrypted payloads small.
+- Each user has one live connection. A new connection for the same user replaces the previous one in the hub.
+- Unknown `type` values (e.g. an application-level `ping`) and malformed JSON are ignored.
+- The server never echoes a sent event back to the sender.
-```
-WebSocket error: Unexpected server response: 404
+### Direct vs. group targeting
+
+Every event targets **either** a user (`receiver_id`) **or** a group (`group_id`), never both. For group events, the server fans the event out to every member of the group except the sender.
+
+### Message content format (E2EE)
+
+The server stores and relays `content` as an opaque string. The Elephant app sends a JSON-encoded envelope:
+
+```json
+"{\"type\": , \"ciphertext\": \"\"}"
```
-### Notes
-* If chat is done in group group_id will be used instead of receiver_id and vice versa.
+- **Direct messages:** `ciphertext` is a base64-encoded Signal Protocol message, and `type` is the libsignal ciphertext message type. The first message to a new contact is a PreKey message built from the contact's [prekey bundle](#2-fetch-prekey-bundle).
+- **Group messages:** currently sent with `type: 0`, and `ciphertext` holds the **plaintext** message. Group messages are not yet end-to-end encrypted.
### 2. Client to Server Messages
#### 1. Send Chat Message
-Sends a direct or group message.
-##### Request Body
-No Reply
-```json
-{
- "type":"chat",
- "receiver_id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
- "content":""
-}
-```
-Reply
+Persists the message and delivers it live to online recipients. Offline recipients pick it up through the history endpoints.
+
```json
{
- "type":"chat",
- "receiver_id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
- "content":"",
- "reply_to_message_id":""
+ "type": "chat",
+ "message_id": "cccccccc-cccc-cccc-cccc-cccccccccccc",
+ "receiver_id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
+ "content": "",
+ "reply_to_message_id": "dddddddd-dddd-dddd-dddd-dddddddddddd"
}
```
-##### Notes
-* If chat is done in group group_id will be used instead of receiver_id and vice versa.
+- `message_id` is optional. If you send a client-generated UUID, the server uses it as the stored message ID, which lets offline-queued sends stay idempotent with the local DB. If you omit it, the server generates one.
+- `reply_to_message_id` is optional.
+- For a group, send `group_id` instead of `receiver_id`.
-#### 2. Read Recipet
-Marks messages as read.
+#### 2. Read Receipt
-##### Request Body
```json
{
- "type":"read_receipt",
- "receiver_id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
+ "type": "read_receipt",
+ "receiver_id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
}
```
-##### Notes
-* If chat is done in group group_id will be used instead of receiver_id and vice versa.
+
+- **Direct:** marks every unread message from `receiver_id` to you as read, and forwards the receipt to that user.
+- **Group** (`group_id`): updates your `last_read_at` for the group, and forwards the receipt to the other members.
#### 3. Request Online Status
-Checks whether a specific user is currently connected.
-##### Request Body
```json
{
- "type":"request_status",
- "receiver_id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
+ "type": "request_status",
+ "receiver_id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
}
```
-##### Notes
-* If chat is done in group group_id will be used instead of receiver_id and vice versa.
-#### 4. Typing Indicator
+The server replies directly with a [`user_status`](#5-user-status) event.
+#### 4. Typing Indicator
-##### Request Body
```json
{
"type": "typing",
- "receiver_id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
+ "receiver_id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
+ "content": "true"
}
```
-##### Notes
-* If chat is done in group group_id will be used instead of receiver_id and vice versa.
+
+`content` is relayed as-is. The app sends `"true"` when typing starts and `"false"` when it stops. Use `group_id` for groups.
### 3. Server to Client Messages
-#### 1. Incoming Chat Message (no reply)
-Delivered when another user sends you a direct or group message.
+#### 1. Incoming Chat Message
-##### Response
```json
{
"type": "chat",
"sender_id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
"receiver_id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
"content": "",
- "message_id": "",
+ "message_id": "cccccccc-cccc-cccc-cccc-cccccccccccc",
"timestamp": ""
}
```
-##### Notes
-* If chat is done in group group_id will be used instead of receiver_id and vice versa.
-#### 2. Incoming Chat Message (with reply)
-Delivered when another user replies to a message. Includes the original quoted message.
+For group messages, `group_id` is set instead of `receiver_id`.
+
+#### 2. Incoming Chat Message (reply)
+
+When `reply_to_message_id` resolves to a stored message, the event includes a preview of the quoted message. In this form, both `receiver_id` and `group_id` are always present, and the unused one is an empty string.
-##### Response
```json
{
"type": "chat",
"sender_id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
"receiver_id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
+ "group_id": "",
"content": "",
- "message_id": "",
- "reply_to_message_id": "",
- "timestamp": "",
+ "message_id": "cccccccc-cccc-cccc-cccc-cccccccccccc",
+ "reply_to_message_id": "dddddddd-dddd-dddd-dddd-dddddddddddd",
+ "timestamp": "",
"quoted_message": {
- "content": "",
- "sender_id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
+ "id": "dddddddd-dddd-dddd-dddd-dddddddddddd",
+ "sender_id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
+ "content": ""
}
}
```
-##### Notes
-* If chat is done in group group_id will be used instead of receiver_id and vice versa.
#### 3. Typing Indicator
-Delivered when another user is typing to you or in a shared group.
-##### Response
```json
{
"type": "typing",
"sender_id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
- "receiver_id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
+ "receiver_id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
+ "content": "true"
}
```
-##### Notes
-* If chat is done in group group_id will be used instead of receiver_id and vice versa.
#### 4. Read Receipt
-Delivered when the recipient reads your message.
-##### Response
```json
{
"type": "read_receipt",
"sender_id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
"receiver_id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
- "timestamp": ""
+ "timestamp": ""
}
```
-##### Notes
-* If chat is done in group group_id will be used instead of receiver_id and vice versa.
+
+`sender_id` is the user who read the messages.
#### 5. User Status
-Broadcast to all connected clients when a user connects or disconnects. Also sent as a direct response to request_status
-##### Response
+Broadcast to every connected client when a user connects or disconnects. Also sent directly in response to `request_status`.
+
```json
{
"type": "user_status",
@@ -580,33 +629,56 @@ Broadcast to all connected clients when a user connects or disconnects. Also sen
"online": true
}
```
-##### Notes
-* If chat is done in group group_id will be used instead of receiver_id and vice versa.
+
+---
## Messages Endpoints
+### Message object
+
+```json
+{
+ "id": "cccccccc-cccc-cccc-cccc-cccccccccccc",
+ "sender_id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
+ "receiver_id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
+ "group_id": "",
+ "content": "",
+ "created_at": "",
+ "is_read": false,
+ "edited_at": "",
+ "reply_to_message_id": "dddddddd-dddd-dddd-dddd-dddddddddddd",
+ "quoted_message": {
+ "id": "dddddddd-dddd-dddd-dddd-dddddddddddd",
+ "sender_id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
+ "content": ""
+ }
+}
+```
+
+`receiver_id`, `group_id`, `edited_at`, `reply_to_message_id` and `quoted_message` are omitted when empty.
+
### 1. Send Message
-- **URL:** `/api/messages/`
+REST fallback for direct messages. The message is stored but **not** pushed over the WebSocket.
+
+- **URL:** `/api/messages`
- **Method:** `POST`
-- **Headers:** `Content-Type: application/json`
- **Authentication Required:** `YES`
-- **curl:** `curl -X POST "http://HOST:PORT/api/messages/" -H "Content-Type: application/json" -H "Authorization: Bearer " -d '{ "receiver_id": "", "content": "" }'`
+- **curl:** `curl -X POST "http://HOST:PORT/api/messages" -H "Content-Type: application/json" -H "Authorization: Bearer " -d '{ "receiver_id": "", "content": "" }'`
#### Request Body
```json
{
- "receiver_id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
- "content": ""
+ "receiver_id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
+ "content": "",
+ "reply_to_message_id": "dddddddd-dddd-dddd-dddd-dddddddddddd"
}
```
-#### Expected Response
+`reply_to_message_id` is optional.
-##### `201 Created`
-
-Returned when message is successfully sent
+#### `201 Created`
```json
{
@@ -614,39 +686,34 @@ Returned when message is successfully sent
"data": {
"id": "cccccccc-cccc-cccc-cccc-cccccccccccc",
"sender_id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
- "receiver_id": "bbbbbbbb-bbbb-bbbb-bbbbbbbbbbbb",
+ "receiver_id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
"content": "",
- "created_at": ""
+ "created_at": "",
+ "is_read": false
}
}
```
-##### `401 Unauthorized`
+#### `400 Bad Request`
-Returned when access token is expired or incorrect
+`"Invalid json payload structure"`, `"Receiver identity token required"`, `"message content cannot be empty"`, `"cannot send a message to yourself"`, or a database error message.
-```json
-{
- "success": false,
- "error": "Access token missing, expired or malformed"
-}
-```
+### 2. Direct Message History
-### 2. Message History
+Returns messages between you and another user, newest first.
-To get message history with specific user using user id
-
-- **URL:** `/api/messages/?with={receiver_id}&before={time}&limit={limit}`
+- **URL:** `/api/messages?with={user_id}&before={timestamp}&limit={limit}`
- **Method:** `GET`
-- **Headers:** `Content-Type: application/json`
- **Authentication Required:** `YES`
-- **curl:** `curl -X GET "http://HOST:PORT/api/messages/?with={receiver_id}&before={time}&limit={limit}" -H "Authorization: Bearer "`
-
-#### Expected Response
+- **curl:** `curl "http://HOST:PORT/api/messages?with={user_id}&limit=50" -H "Authorization: Bearer "`
-##### `200 OK`
+| Param | Required | Default | Notes |
+|---|---|---|---|
+| `with` | Yes | — | The other user's ID |
+| `before` | No | now + 1 minute | RFC3339 timestamp. Returns messages created strictly before it. To page backwards, pass the `created_at` of the oldest message you have |
+| `limit` | No | `50` | Values outside 1–100 fall back to 50 |
-Returned when data is successfully retrieved
+#### `200 OK`
```json
{
@@ -655,85 +722,145 @@ Returned when data is successfully retrieved
{
"id": "cccccccc-cccc-cccc-cccc-cccccccccccc",
"sender_id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
- "receiver_id": "bbbbbbbb-bbbb-bbbb-bbbbbbbbbbbb",
+ "receiver_id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
"content": "",
- "created_at": ""
+ "created_at": "",
+ "is_read": true,
+ "edited_at": "",
+ "reply_to_message_id": "dddddddd-dddd-dddd-dddd-dddddddddddd",
+ "quoted_message": {
+ "id": "dddddddd-dddd-dddd-dddd-dddddddddddd",
+ "sender_id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
+ "content": ""
+ }
}
]
}
```
-##### `401 Unauthorized`
-
-Returned when access token is expired or incorrect
+#### `400 Bad Request`
```json
{
"success": false,
- "error": "Access token missing, expired or malformed"
+ "error": "Missing target 'with' user query constraint"
}
```
+#### `500 Internal Server Error`
+
+`"Failed to pull chat logs"`.
+
### 3. Conversations
-To retrieve user conversations
+
+Returns one entry per direct chat and per group you belong to, ordered by latest message (newest first). Groups with no messages yet are not listed.
- **URL:** `/api/messages/conversations`
- **Method:** `GET`
-- **Headers:** `Content-Type: application/json`
- **Authentication Required:** `YES`
-- **curl:** `curl -X GET "http://HOST:PORT/api/messages/conversations" -H "Authorization: Bearer "`
+- **curl:** `curl "http://HOST:PORT/api/messages/conversations" -H "Authorization: Bearer "`
-#### Expected Response
+#### `200 OK`
-##### `200 OK`
-Conversations is successfully retrieved.
```json
{
"success": true,
"data": [
{
- "id": "",
- "type": "",
- "name": "",
- "display_name": "",
+ "id": "",
+ "type": "direct",
+ "name": "",
+ "display_name": "",
"last_message": "",
- "last_message_time": "",
- "is_read": false/true"",
- "unread_count": 0""
+ "last_message_time": "",
+ "sender_id": "",
+ "is_read": false,
+ "unread_count": 3
}
]
}
```
-##### `401 Unauthorized`
-Access token is missing or expired.
+- `type` is `direct` or `group`.
+- **Direct:** `is_read` is the read flag of the last message, and `unread_count` counts unread messages sent to you.
+- **Group:** `is_read` is true if the last message is no newer than your `last_read_at`, and `unread_count` counts other members' messages since then.
+
+#### `500 Internal Server Error`
+
+`"Failed to compile inbox conversations"`.
+
+### 4. Edit Message
+
+Replaces the content of a message you sent. Edits are only allowed within **15 minutes** of `created_at`, and they are not broadcast over the WebSocket. For E2EE direct messages, the client must send newly encrypted content.
+
+- **URL:** `/api/messages/{id}`
+- **Method:** `PUT`
+- **Authentication Required:** `YES`
+- **curl:** `curl -X PUT "http://HOST:PORT/api/messages/{id}" -H "Content-Type: application/json" -H "Authorization: Bearer " -d '{ "content": "" }'`
+
+#### Request Body
+
```json
{
- "success": false,
- "error": "Access token missing, expired or malformed"
+ "content": ""
}
```
-### 4. Read
-To get read mark for a user
+#### `200 OK`
+
+```json
+{
+ "success": true,
+ "data": {
+ "id": "cccccccc-cccc-cccc-cccc-cccccccccccc",
+ "sender_id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
+ "receiver_id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
+ "content": "",
+ "created_at": "",
+ "is_read": false,
+ "edited_at": ""
+ }
+}
+```
+
+#### `400 Bad Request`
+
+`"Message ID required"`, `"Invalid json payload structure"` or `"Content cannot be empty"`.
+
+#### `403 Forbidden`
+
+```json
+{ "success": false, "error": "You can only edit your own messages" }
+```
+```json
+{ "success": false, "error": "Message can no longer be edited (15 minute window expired)" }
+```
+
+#### `404 Not Found`
+
+```json
+{ "success": false, "error": "Message not found" }
+```
+
+### 5. Mark Direct Messages Read
+
+Marks every unread message that `sender_id` sent to you as read. This is the REST equivalent of the WebSocket `read_receipt`, but the sender is not notified.
- **URL:** `/api/messages/read`
- **Method:** `POST`
-- **Headers:** `Content-Type: application/json`
- **Authentication Required:** `YES`
-- **curl:** `curl -X POST "http://HOST:PORT/api/messages/read" -H "Authorization: Bearer " -d '{ "sender_id": "" }'`
+- **curl:** `curl -X POST "http://HOST:PORT/api/messages/read" -H "Content-Type: application/json" -H "Authorization: Bearer " -d '{ "sender_id": "" }'`
#### Request Body
+
```json
{
- "sender_id":""
+ "sender_id": ""
}
```
-#### Expected Responses
+#### `200 OK`
-##### `200 OK`
```json
{
"success": true,
@@ -741,76 +868,82 @@ To get read mark for a user
}
```
-##### `401 Unauthorized`
+#### `400 Bad Request`
+
+`"Invalid payload layout"` or `"Sender identification missing"`.
+
+---
+
+## Group Endpoints
+
+Routes under `/api/groups/{id}` require group membership. When the caller isn't a member, or the group doesn't exist, they return:
+
```json
{
"success": false,
- "error": "Access token missing, expired or malformed"
+ "error": "Access denied: you are not a member of this group"
}
```
-## Group Endpoints
+Admin-only routes additionally return `403` for non-admins:
+
+```json
+{
+ "success": false,
+ "error": "Administrative clearance privileges required"
+}
+```
+
+The group creator becomes its `admin`, and added users join as `member`.
### 1. Create Group
-- **URL:** `/api/groups/`
+
+- **URL:** `/api/groups`
- **Method:** `POST`
-- **Headers:** `Content-Type: application/json`
- **Authentication Required:** `YES`
-- **curl:** `curl -X POST "http://HOST:PORT/api/groups/" -H "Authorization: Bearer " -d '{ "name" : "group_name" }'`
+- **curl:** `curl -X POST "http://HOST:PORT/api/groups" -H "Content-Type: application/json" -H "Authorization: Bearer " -d '{ "name": "" }'`
#### Request Body
+
```json
{
- "name":""
+ "name": ""
}
```
-#### Expected Responses
+#### `201 Created`
-##### `201 Created`
-Returned when group is successfully created
```json
{
"success": true,
"data": {
"id": "",
- "name": "noobs",
+ "name": "",
"created_by": "",
"created_at": ""
}
}
```
-##### `401 Unauthorized`
-Returned when access token is expired or incorrect.
-```json
-{
- "success": false,
- "error": "Access token missing, expired or malformed"
-}
-```
+#### `400 Bad Request`
+
+`"Invalid payload group name configuration"` (empty or missing name).
+
+### 2. List My Groups
-### 2. Get Groups
+Returns the groups you belong to, newest first.
-- **URL:** `/api/groups/`
+- **URL:** `/api/groups`
- **Method:** `GET`
-- **Headers:** `Content-Type: application/json`
- **Authentication Required:** `YES`
-- **curl:** `curl -X GET "http://HOST:PORT/api/groups/" -H "Authorization: Bearer "`
+- **curl:** `curl "http://HOST:PORT/api/groups" -H "Authorization: Bearer "`
-#### Expected Response
+#### `200 OK`
-##### `200 OK`
```json
{
"success": true,
"data": [
- {
- "id": "",
- "name": "",
- "created_by": "",
- "created_at": ""
- },
{
"id": "",
"name": "",
@@ -821,78 +954,47 @@ Returned when access token is expired or incorrect.
}
```
-##### `401 Unauthorized`
-```json
-{
- "success": false,
- "error": "Access token missing, expired or malformed"
-}
-```
-
-### 3. Get Group by Group ID
+### 3. Get Group by ID
- **URL:** `/api/groups/{id}`
- **Method:** `GET`
-- **Headers:** `Content-Type: application/json`
-- **Authentication Required:** `YES`
-- **curl:** `curl -X GET "http://HOST:PORT/api/groups/{id}" -H "Authorization: Bearer "`
-
-#### Expected Response
+- **Authentication Required:** `YES` (member)
+- **curl:** `curl "http://HOST:PORT/api/groups/{id}" -H "Authorization: Bearer "`
-##### `200 OK`
-Returned when group with given id is retrieved
+#### `200 OK`
```json
{
"success": true,
- "data": [
- {
- "id": "",
- "name": "",
- "created_by": "",
- "created_at": "Timestamp"
- },
- {
- "id": "",
- "name": "",
- "created_by": "",
- "created_at": "Timestamp"
- }
- ]
+ "data": {
+ "id": "",
+ "name": "",
+ "created_by": "",
+ "created_at": ""
+ }
}
```
-##### `403 Forbidden`
-Returned when you are not a member of group with given group id or the group id is incorrect
+#### `404 Not Found`
-```json
-{
- "success": false,
- "error": "Access denied: you are not a member of this group"
-}
-```
+`"Group not found"`.
-##### `401 Unauthorized`
-Returned when access token is expired or incorrect
-```json
-{
- "success": false,
- "error": "Access token missing, expired or malformed"
-}
-```
+### 4. Group Message History
-### 4. Get messages of group
+Returns group messages, newest first.
-- **URL:** `/api/groups/{id}/messages`
+- **URL:** `/api/groups/{id}/messages?before={timestamp}&limit={limit}`
- **Method:** `GET`
-- **Headers:** `Content-Type: application/json`
-- **Authentication Required:** `YES`
-- **curl:** `curl -X GET "http://HOST:PORT/api/groups/{id}/messages" -H "Authorization: Bearer "`
+- **Authentication Required:** `YES` (member)
+- **curl:** `curl "http://HOST:PORT/api/groups/{id}/messages?limit=50" -H "Authorization: Bearer "`
+
+| Param | Default | Notes |
+|---|---|---|
+| `before` | now | RFC3339 timestamp. Returns messages created strictly before it |
+| `limit` | `50` | Values outside 1–100 fall back to 50 |
-#### Expected Response
+#### `200 OK`
-##### `200 OK`
-Returned when messages is successfully retrieved
```json
{
"success": true,
@@ -903,42 +1005,32 @@ Returned when messages is successfully retrieved
"group_id": "",
"content": "",
"created_at": "",
- "is_read": (bool)""
+ "is_read": false,
+ "edited_at": "",
+ "reply_to_message_id": "",
+ "quoted_message": {
+ "id": "",
+ "sender_id": "",
+ "content": ""
+ }
}
]
}
```
-##### `403 Forbidden`
-Returned if user are not member of the group with given group id or group id is incorrect
-```json
-{
- "success": false,
- "error": "Access denied: you are not a member of this group"
-}
-```
+`is_read` is always `false` for group messages. Use the `unread_count` in [Conversations](#3-conversations) to track group read state.
-##### `401 Unauthorized`
-Returned if access token is expired or incorrect
-```json
-{
- "success": false,
- "error": "Access token missing, expired or malformed"
-}
-```
+### 5. Group Members
-### 5. Group members
-Get members of a group
+Members are listed admins first, then by join date.
- **URL:** `/api/groups/{id}/members`
- **Method:** `GET`
-- **Headers:** `Content-Type: application/json`
-- **Authentication Required:** `YES`
-- **curl:** `curl -X GET "http://HOST:PORT/api/groups/{id}/members" -H "Authorization: Bearer "`
+- **Authentication Required:** `YES` (member)
+- **curl:** `curl "http://HOST:PORT/api/groups/{id}/members" -H "Authorization: Bearer "`
-#### Expected Response
+#### `200 OK`
-##### `200 OK`
```json
{
"success": true,
@@ -947,52 +1039,31 @@ Get members of a group
"user_id": "",
"username": "",
"display_name": "",
- "role": "",
- "joined_at": "Timestamp"
+ "role": "admin",
+ "joined_at": ""
},
{
"user_id": "",
"username": "",
"display_name": "",
- "role": "",
- "joined_at": "Timestamp"
+ "role": "member",
+ "joined_at": ""
}
]
}
```
-* **Note:** Role can either be admin or member.
-
-##### `403 Forbidden`
-Returned when user is not a member of group with given group id or group id is incorrect or if user is not admin of group
-```json
-{
- "success": false,
- "error": "Access denied: you are not a member of this group"
-}
-```
-
-##### `401 Unauthorized`
-Returned when access token is expired or incorrect
-```json
-{
- "success": false,
- "error": "Access token missing, expired or malformed"
-}
-```
+`role` is either `admin` or `member`.
### 6. Leave Group
- **URL:** `/api/groups/{id}/leave`
- **Method:** `POST`
-- **Headers:** `Content-Type: application/json`
-- **Authentication Required:** `YES`
+- **Authentication Required:** `YES` (member)
- **curl:** `curl -X POST "http://HOST:PORT/api/groups/{id}/leave" -H "Authorization: Bearer "`
-#### Expected Response
+#### `200 OK`
-##### `200 OK`
-Returned when user successfully left the group
```json
{
"success": true,
@@ -1000,181 +1071,278 @@ Returned when user successfully left the group
}
```
-##### `403 Forbidden`
-Returned when user is not a member of group with given group id or group id is incorrect or if user is not admin of group
+#### `500 Internal Server Error`
+
+`"Failed to leave the group"`.
+
+### 7. Update Group Details
+
+- **URL:** `/api/groups/{id}`
+- **Method:** `PATCH`
+- **Authentication Required:** `YES` (admin)
+- **curl:** `curl -X PATCH "http://HOST:PORT/api/groups/{id}" -H "Content-Type: application/json" -H "Authorization: Bearer " -d '{ "name": "" }'`
+
+#### Request Body
+
```json
{
- "success": false,
- "error": "Access denied: you are not a member of this group"
+ "name": ""
}
```
-##### `401 Unauthorized`
-Returned when access token is expired or incorrect
+#### `200 OK`
+
```json
{
- "success": false,
- "error": "Access token missing, expired or malformed"
+ "success": true,
+ "data": "Group details updated successfully"
}
```
-### 7. Update Group Details
+#### `400 Bad Request`
-- **URL:** `/api/groups/{id}/`
-- **Method:** `PATCH`
-- **Headers:** `Content-Type: application/json`
-- **Authentication Required:** `YES`
-- **Admin Access Required:** `YES`
-- **curl:** `curl -X PATCH "http://HOST:PORT/api/groups/{id}/" -H "Authorization: Bearer " -d '{ "name":"" }'`
+`"Invalid group name"`.
+
+### 8. Add Member to Group
+
+Adding a user who is already a member is a no-op that still returns `200`.
+
+- **URL:** `/api/groups/{id}/members`
+- **Method:** `POST`
+- **Authentication Required:** `YES` (admin)
+- **curl:** `curl -X POST "http://HOST:PORT/api/groups/{id}/members" -H "Content-Type: application/json" -H "Authorization: Bearer " -d '{ "user_id": "" }'`
#### Request Body
+
```json
{
- "name":""
+ "user_id": ""
}
```
-#### Expected Response
+#### `200 OK`
-##### `200 OK`
-Returned when group details are successfully updated.
```json
{
"success": true,
- "data": "Group details updated successfully"
+ "data": "User attached to channel context cleanly"
}
```
-##### `403 Forbidden`
-Returned when user is not a member of group with given group id or group id is incorrect or if user is not admin of group
+#### `400 Bad Request`
+
+`"Missing user target value"`.
+
+#### `500 Internal Server Error`
+
+Returned when the user doesn't exist:
+
```json
{
"success": false,
- "error": "Access denied: you are not a member of this group"
+ "error": "Failed to add member to channel"
}
```
+
+### 9. Remove Member from Group
+
+- **URL:** `/api/groups/{id}/members/{userId}`
+- **Method:** `DELETE`
+- **Authentication Required:** `YES` (admin)
+- **curl:** `curl -X DELETE "http://HOST:PORT/api/groups/{id}/members/{userId}" -H "Authorization: Bearer "`
+
+#### `200 OK`
+
```json
{
- "success": false,
- "error": "Administrative clearance privileges required"
+ "success": true,
+ "data": "Member detached from group context cleanly"
}
```
-##### `401 Unauthorized`
-Returned when access token is expired or incorrect
+#### `500 Internal Server Error`
+
```json
{
"success": false,
- "error": "Access token missing, expired or malformed"
+ "error": "Failed to drop target user from channel"
}
```
-### 8. Add Member to group
-- **URL:** `/api/groups/{id}/members`
+---
+
+## E2EE Endpoints
+
+The server is a public-key directory for the [Signal Protocol](https://signal.org/docs/) (X3DH + Double Ratchet). It stores only **public** keys, and private keys never leave the device. All keys are base64-encoded strings. Keys are stored per `(user_id, device_id)`, and the app currently always uses `device_id = "main"`.
+
+### 1. Upload Prekey Bundle
+
+Creates or replaces your device's identity key and signed prekey, and **appends** the supplied one-time prekeys.
+
+If the identity key differs from the one previously stored for this device, every other user's verification of you is reset to unverified.
+
+- **URL:** `/api/e2ee/keys`
- **Method:** `POST`
-- **Headers:** `Content-Type: application/json`
- **Authentication Required:** `YES`
-- **Admin Access Required:** `YES`
-- **curl:** `curl -X POST "http://HOST:PORT/api/groups/{id}/members" -H "Authorization: Bearer " -d '{ "user_id":"" }'`
+- **curl:** `curl -X POST "http://HOST:PORT/api/e2ee/keys" -H "Content-Type: application/json" -H "Authorization: Bearer " -d @bundle.json`
#### Request Body
+
```json
{
- "user_id":""
+ "device_id": "main",
+ "identity_key": "",
+ "signed_prekey": "",
+ "signature": "",
+ "one_time_prekeys": [
+ { "id": 1, "content": "" },
+ { "id": 2, "content": "" }
+ ]
}
```
-#### Expected Responses
+`device_id` defaults to `main`. `identity_key`, `signed_prekey` and `signature` are required.
+
+#### `200 OK`
-##### `200 OK`
-Returned when user is successfully added to group
```json
{
"success": true,
- "data": "User attached to channel context cleanly"
+ "data": {
+ "message": "Prekey cryptographical parameters saved successfully",
+ "requires_refill": false
+ }
}
```
-##### `500 Internal Server Error`
-Returned when user with given user id does not exist
+`requires_refill` is `true` when fewer than 10 one-time prekeys remain for the device.
+
+#### `400 Bad Request`
+
+`"Malformatted JSON configuration payload structure"`.
+
+#### `500 Internal Server Error`
+
+Returned when a required key is missing (`"missing core payload parameters for prekey bundle submission"`) or the database write fails.
+
+### 2. Fetch Prekey Bundle
+
+Returns a user's public keys so that you can start a session with them. Each call **consumes** (deletes) one one-time prekey. When the user has none left, the `one_time_prekey_*` fields are omitted.
+
+- **URL:** `/api/e2ee/bundle/{userId}?device_id=main`
+- **Method:** `GET`
+- **Authentication Required:** `YES`
+- **curl:** `curl "http://HOST:PORT/api/e2ee/bundle/{userId}?device_id=main" -H "Authorization: Bearer "`
+
+#### `200 OK`
+
```json
{
- "success": false,
- "error": "Failed to add member to channel"
+ "success": true,
+ "data": {
+ "user_id": "",
+ "device_id": "main",
+ "identity_key": "",
+ "signed_prekey": "",
+ "signature": "",
+ "one_time_prekey_id": 1,
+ "one_time_prekey_body": ""
+ }
}
```
-##### `403 Forbidden`
-Returned when user is not a member of group with given group id or group id is incorrect or if user is not admin of group
+#### `404 Not Found`
+
+Returned when the user hasn't uploaded keys yet:
+
```json
{
"success": false,
- "error": "Access denied: you are not a member of this group"
+ "error": "device keys not found for target identity profile"
}
```
+
+### 3. Set Contact Verification
+
+Records whether you have verified a contact's identity key (e.g. by comparing safety numbers or scanning a QR code).
+
+- **URL:** `/api/e2ee/verify`
+- **Method:** `POST`
+- **Authentication Required:** `YES`
+- **curl:** `curl -X POST "http://HOST:PORT/api/e2ee/verify" -H "Content-Type: application/json" -H "Authorization: Bearer " -d '{ "verified_user_id": "", "is_verified": true }'`
+
+#### Request Body
+
```json
{
- "success": false,
- "error": "Administrative clearance privileges required"
+ "verified_user_id": "",
+ "is_verified": true
}
```
-##### `401 Unauthorized`
-Returned when access token is expired or incorrect
+#### `200 OK`
+
```json
{
- "success": false,
- "error": "Access token missing, expired or malformed"
+ "success": true,
+ "data": { "message": "Contact verification status updated" }
}
```
-### 9. Remove User from Group
-- **URL:** `/api/groups/{id}/members/{user_id}`
-- **Method:** `DELETE`
-- **Headers:** `Content-Type: application/json`
+#### `400 Bad Request`
+
+`"Invalid JSON payload structure"`, `"target verified_user_id is required"` or `"cannot verify own user identity"`.
+
+### 4. Get Contact Verification
+
+- **URL:** `/api/e2ee/verify/{userId}`
+- **Method:** `GET`
- **Authentication Required:** `YES`
-- **Admin Access Required:** `YES`
-- **curl:** `curl -X DELETE "http://HOST:PORT/api/groups/{id}/members/{user_id}" -H "Authorization: Bearer "`
+- **curl:** `curl "http://HOST:PORT/api/e2ee/verify/{userId}" -H "Authorization: Bearer "`
+
+#### `200 OK`
-#### Expected Response
+`is_verified` is `false` if you never verified this user, or if they have uploaded a new identity key since you did.
-##### `200 OK`
-Returned when user is successfully removed from group
```json
{
"success": true,
- "data": "Member detached from group context cleanly"
-}
-```
-##### `500 Internal Server Error`
-Returned when user with given user id does not exist
-```json
-{
- "success": false,
- "error": "Failed to add member to channel"
+ "data": {
+ "user_id": "",
+ "verified_user_id": "",
+ "is_verified": true
+ }
}
```
-##### `403 Forbidden`
-Returned when user is not a member of group with given group id or group id is incorrect or if user is not admin of group
+### 5. Reset Encryption Keys
+
+Deletes all of your devices, identity keys and one-time prekeys, and marks every other user's verification of you as unverified. Afterwards, generate and [upload](#1-upload-prekey-bundle) a new bundle.
+
+- **URL:** `/api/e2ee/reset`
+- **Method:** `POST`
+- **Authentication Required:** `YES`
+- **curl:** `curl -X POST "http://HOST:PORT/api/e2ee/reset" -H "Content-Type: application/json" -H "Authorization: Bearer " -d '{ "password": "" }'`
+
+#### Request Body
+
```json
{
- "success": false,
- "error": "Access denied: you are not a member of this group"
+ "password": ""
}
```
+
+#### `200 OK`
+
```json
{
- "success": false,
- "error": "Administrative clearance privileges required"
+ "success": true,
+ "data": {
+ "message": "All encryption keys wiped successfully. Please re-generate and upload a new prekey bundle."
+ }
}
```
-##### `401 Unauthorized`
-Returned when access token is expired or incorrect
-```json
-{
- "success": false,
- "error": "Access token missing, expired or malformed"
-}
-```
\ No newline at end of file
+#### `401 Unauthorized`
+
+`"password required for re-authentication"`, `"invalid password re-authentication attempt"` or `"user account context not found"`.
diff --git a/metadata/en-US/long_description.txt b/metadata/en-US/long_description.txt
index 3b3a24c..c82affa 100644
--- a/metadata/en-US/long_description.txt
+++ b/metadata/en-US/long_description.txt
@@ -1,3 +1,13 @@
-Elephant is a privacy-first messaging application made with Go and Flutter.
-It supports secure authentication, user discovery, and real-time chat over WebSocket,
-with all communication TLS-protected between client and server.
\ No newline at end of file
+Elephant is a privacy-first, open-source messaging application made with Go and Flutter.
+
+Features:
+- End-to-end encrypted direct messages using the Signal Protocol
+- Contact identity verification
+- Group chats with admin and member roles
+- Real-time delivery over WebSocket, with typing indicators, read receipts and online status
+- Replies and message editing
+- Sign in with a username and password, Google or GitHub
+- Encrypted on-device message storage, with offline message queueing
+- Connect to the public server or to your own self-hosted Elephant server
+
+Note: group messages are not yet end-to-end encrypted.
diff --git a/mobile/README.md b/mobile/README.md
index 3b00f34..70183ae 100644
--- a/mobile/README.md
+++ b/mobile/README.md
@@ -1,17 +1,84 @@
-# mob
+# Elephant Mobile
-A new Flutter project.
+The Flutter client for [Elephant](../README.md), a privacy-first messenger. Android is the primary target, and release APKs are published on [GitHub Releases](https://github.com/commandlinecoding/elephant/releases). The app ID is `in.commandlinecoding.elephant`.
## Getting Started
-This project is a starting point for a Flutter application.
+Requirements: a Flutter SDK with Dart `^3.12.0`, plus the Android SDK. CI uses Java 25 (Temurin) for Android builds.
-A few resources to get you started if this is your first Flutter project:
+```bash
+cd mobile
+flutter pub get
+flutter run
+```
-- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter)
-- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
-- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
+### Pointing the app at a server
-For help getting started with Flutter development, view the
-[online documentation](https://docs.flutter.dev/), which offers tutorials,
-samples, guidance on mobile development, and a full API reference.
+The app connects to the public server `elephant.commandlinecoding.in:443` by default. To use a local or self-hosted server, tap **"Connected to: … ⚙️"** on the login screen and enter a host and port. The values are stored in secure storage.
+
+- Port `443` (or any `*.commandlinecoding.in` host) uses `https://` / `wss://`. Every other port uses plain `http://` / `ws://`.
+- From the Android emulator, the host machine is reachable at `10.0.2.2`, e.g. host `10.0.2.2`, port `3000`.
+
+### Building release APKs
+
+```bash
+flutter build apk --release --split-per-abi --no-tree-shake-icons # per-ABI APKs
+flutter build apk --release --no-tree-shake-icons # universal APK
+```
+
+The [android-release workflow](../.github/workflows/android-release.yaml) runs the same commands on every `v*` tag and attaches the APKs to the GitHub Release.
+
+### Launcher icon and name
+
+The launcher icon is generated from `assets/launcher/elephant.png`. The configuration is in `pubspec.yaml` under `flutter_launcher_icons` and `launcher_name`.
+
+```bash
+dart run flutter_launcher_icons
+```
+
+## Architecture
+
+```
+lib/
+├── main.dart # App bootstrap and provider setup
+├── core/constants.dart # Server host/port (Env) and URL builders
+├── controllers/
+│ ├── auth_state.dart # Login/register/OAuth deep-link handling, session state
+│ └── chat/ # Inbox, active chat, connection, search, group details
+├── models/ # User, Message, Conversation, Group, InboxItem, WS event
+├── pages/ # auth, home, chat, new chat, settings screens
+├── providers/ # Group controller and timer providers
+├── services/
+│ ├── api_services.dart # Dio client with automatic token refresh
+│ ├── auth_service.dart # Token storage (flutter_secure_storage)
+│ ├── ws_service.dart # WebSocket connection, heartbeat, outgoing events
+│ ├── chat/chat_event_handler.dart # Incoming WS events → local DB + UI
+│ ├── chat/chat_sync_service.dart # Replays the offline action queue
+│ ├── db_services.dart # SQLCipher-encrypted local database
+│ ├── signal_service.dart # Signal Protocol keys, sessions, encrypt/decrypt
+│ └── sqlite_signal_store.dart # Signal key/session store backed by the local DB
+├── themes/ # Light/dark themes and theme provider
+└── widgets/ # Shared chat and home widgets
+```
+
+### Authentication
+
+- Username/password login and registration go through `/api/auth/*`. Access and refresh tokens are stored in `flutter_secure_storage`.
+- `ApiService` refreshes the access token automatically when it expires. Concurrent requests share a single refresh call.
+- Google/GitHub sign-in opens `/api/auth/{provider}` in the browser. The server redirects back to `elephant://oauth-callback?access_token=…&refresh_token=…`, and `app_links` picks that up.
+
+### Messaging and offline support
+
+- Messages are written to the local database first (`sync_status = pending`) and added to `action_queue`. They are sent over the WebSocket when connected.
+- `ChatSyncService` replays queued actions on reconnect. Each action is retried up to 5 times. The client generates the message UUID, so the server stores the message under the same ID.
+- The local database is encrypted with SQLCipher. Its 32-byte random key is kept in secure storage. If the keystore becomes unreadable, the local database is wiped and recreated.
+
+### End-to-end encryption
+
+- On login, if no keys exist on the device yet, the app generates a Signal identity key pair, a signed prekey and 100 one-time prekeys. It uploads the public halves to `/api/e2ee/keys` with `device_id = "main"`.
+- To message a contact for the first time, the app fetches the contact's bundle from `/api/e2ee/bundle/{userId}` and builds a session (X3DH). Later messages use the Double Ratchet.
+- Direct message `content` is sent as `{"type": , "ciphertext": ""}`.
+- **Group messages are not yet end-to-end encrypted.** They are currently sent as `{"type": 0, "ciphertext": ""}`. Sender Key helpers (`encryptGroupMessage`, `processSenderKeyDistribution`) exist in `SignalService` but aren't wired into sending yet.
+- The **Privacy and security** settings screen can reset all sessions to recover from decryption errors.
+
+See [docs/API_CONTRACTS.md](../docs/API_CONTRACTS.md) for the full server protocol.