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. App Screenshot - ## 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.