274 lines
8.6 KiB
Markdown
274 lines
8.6 KiB
Markdown
|
|
# Proximity — Backend API Specification
|
||
|
|
|
||
|
|
This document defines the server contract that the **Swift iOS client** expects.
|
||
|
|
Every endpoint and payload here mirrors exactly what's implemented in the client
|
||
|
|
(`Services/Network/APIClient.swift` and `Services/Network/RealtimeClient.swift`).
|
||
|
|
The goal is that a backend team can stand up this API from scratch and the
|
||
|
|
existing client will work without changes.
|
||
|
|
|
||
|
|
**Base URL:** `https://api.proximity.app`
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 1. Design Principles
|
||
|
|
|
||
|
|
1. **Zero-knowledge by default.** The server correlates anonymous tokens and
|
||
|
|
relays ciphertext. It never stores identity, plaintext messages, or precise
|
||
|
|
location history.
|
||
|
|
2. **Two identities, never conflated.**
|
||
|
|
- *Account identity* — via auth (`/v1/auth/*`). Private, used for sessions.
|
||
|
|
- *Social identity* — only surfaces in a **mutual** reveal, and even then
|
||
|
|
the server only relays opaque keys; it never stores the profile.
|
||
|
|
3. **Foreground-first.** The server is a correlation + relay layer, not a
|
||
|
|
background continuous-tracking layer. Presence sessions are short-lived.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 2. Authentication
|
||
|
|
|
||
|
|
### 2.1 `POST /v1/auth/exchange`
|
||
|
|
Exchange a provider ID token for a Proximity session.
|
||
|
|
|
||
|
|
**Request:**
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"provider": "apple", // "apple" | "google"
|
||
|
|
"idToken": "<provider-jwt>",
|
||
|
|
"nonce": "<nonce-or-empty>"
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**Response `200` — `Session`:**
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"token": "<proximity-session-bearer-token>",
|
||
|
|
"account": {
|
||
|
|
"id": "<opaque-account-id>",
|
||
|
|
"providerID": "apple",
|
||
|
|
"displayName": "…",
|
||
|
|
"email": "…"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**Server responsibilities:**
|
||
|
|
- Verify the provider ID token with the provider (Apple/Google public keys).
|
||
|
|
- For Apple, verify the `nonce` matches the one in the ID token.
|
||
|
|
- Issue a short-lived Proximity session token (e.g. JWT, 7-day expiry).
|
||
|
|
- Return only the fields the client's `Account` model decodes.
|
||
|
|
|
||
|
|
### 2.2 `POST /v1/auth/revoke`
|
||
|
|
Revoke a session. Bearer token in `Authorization` header. No body expected.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 3. Presence & Token Relay
|
||
|
|
|
||
|
|
> **Key model:** While present, a client broadcasts a **rotating anonymous
|
||
|
|
> token** (see `TokenManager`). The server maps token → connected socket, so it
|
||
|
|
> can push events to the right device without ever knowing the user's identity.
|
||
|
|
|
||
|
|
### 3.1 `POST /v1/token`
|
||
|
|
Register the current anonymous token so the server can route to this device.
|
||
|
|
|
||
|
|
**Request:**
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"token": "<rotating-anon-token>",
|
||
|
|
"tier": 1, // DistanceTier.rawValue: 1=UWB, 2=BLE, 3=area
|
||
|
|
"geohash": "…" // optional, for Tier 3 discovery
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**Auth:** `Authorization: Bearer <session-token>`.
|
||
|
|
|
||
|
|
**Server:** associate `token` with the authenticated session and its open
|
||
|
|
WebSocket. This is the **session → own-token** mapping that lets the server
|
||
|
|
derive who a wave is *from*. Drop associations older than one rotation window
|
||
|
|
(5 min).
|
||
|
|
|
||
|
|
### 3.2 `GET /v1/nearby?geohash=<h>&tier=<n>`
|
||
|
|
Fetch anonymous tokens of present users near a coarse location.
|
||
|
|
|
||
|
|
**Response `200`:**
|
||
|
|
```json
|
||
|
|
["<token-a>", "<token-b>", "…"]
|
||
|
|
```
|
||
|
|
|
||
|
|
**Server:** return tokens registered within the same coarse region for the
|
||
|
|
requested tier. Used for Tier 3 (network) discovery. Tier 1/2 handshakes happen
|
||
|
|
directly over UWB/BLE and are reported via `/v1/encounter`.
|
||
|
|
|
||
|
|
### 3.3 `POST /v1/encounter`
|
||
|
|
Report a local physical encounter (fire-and-forget).
|
||
|
|
|
||
|
|
**Request body** is the client's `Encounter` model:
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"id": "<uuid>",
|
||
|
|
"remoteAnonToken": "<token>",
|
||
|
|
"timestamp": "…",
|
||
|
|
"tier": 1,
|
||
|
|
"engine": "uwb",
|
||
|
|
"geohash": "…",
|
||
|
|
"status": "silhouette"
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**Server:** correlate the two tokens that were physically near each other and
|
||
|
|
record the mutual relationship for later matching. **Do not** store identity or
|
||
|
|
precise location.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 4. The Reveal (Wave) Flow
|
||
|
|
|
||
|
|
This is the heart of the product. The server's job is to reliably relay a
|
||
|
|
mutual wave between two present devices in real time.
|
||
|
|
|
||
|
|
### 4.1 `POST /v1/wave`
|
||
|
|
Send a wave to a silhouette — signals interest in mutual reveal.
|
||
|
|
|
||
|
|
**Request:**
|
||
|
|
```json
|
||
|
|
{ "token": "<remote-anon-token>" }
|
||
|
|
```
|
||
|
|
|
||
|
|
**Auth:** `Authorization: Bearer <session-token>`.
|
||
|
|
|
||
|
|
**Server behavior:**
|
||
|
|
1. Derive the sender's **own** token from the session (via `/v1/token`).
|
||
|
|
2. Record a directional edge `ownToken → remoteToken`.
|
||
|
|
3. Push a **`mutual`** event to the *other* device only if **both** users have
|
||
|
|
waved at each other. (If the other user already waved, this device gets a
|
||
|
|
`mutual` push back immediately.)
|
||
|
|
4. On mutual, create/reuse a `connections` entry and include `connectionID`
|
||
|
|
in the pushed event so messaging can route.
|
||
|
|
5. A wave is **directional**: `A → B` does not reveal until `B → A`.
|
||
|
|
|
||
|
|
### 4.2 `POST /v1/peer-key` — upload own public key
|
||
|
|
Upload the caller's E2E public key so a mutual peer can fetch it.
|
||
|
|
|
||
|
|
**Request:**
|
||
|
|
```json
|
||
|
|
{ "publicKey": "<base64 raw X25519 public key>" }
|
||
|
|
```
|
||
|
|
|
||
|
|
**Auth:** `Authorization: Bearer <session-token>`. The server stores the key
|
||
|
|
against the caller's own token.
|
||
|
|
|
||
|
|
### 4.3 `GET /v1/peer-key?token=<remote-token>` — fetch peer key
|
||
|
|
Fetch the peer's public key to establish the E2E session key.
|
||
|
|
|
||
|
|
**Auth:** `Authorization: Bearer <session-token>`.
|
||
|
|
|
||
|
|
**Response `200`:**
|
||
|
|
```json
|
||
|
|
{ "publicKey": "<base64 raw X25519 public key>" }
|
||
|
|
```
|
||
|
|
|
||
|
|
**Server:** return the peer's key **only** if the caller and peer have a
|
||
|
|
**mutual** wave relationship (`403` otherwise). This is the only
|
||
|
|
identity-bearing data the server touches, and only within an established
|
||
|
|
mutual relationship.
|
||
|
|
|
||
|
|
### 4.4 `POST /v1/block`
|
||
|
|
Permanently block a token. Removes it from the graph in both directions.
|
||
|
|
|
||
|
|
**Request:**
|
||
|
|
```json
|
||
|
|
{ "token": "<remote-anon-token>" }
|
||
|
|
```
|
||
|
|
|
||
|
|
**Server:** delete the edge and refuse future waves/messages from this token.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 5. Messaging (E2E)
|
||
|
|
|
||
|
|
### 5.1 `POST /v1/message`
|
||
|
|
Send an encrypted message. **Only ciphertext leaves the device.**
|
||
|
|
|
||
|
|
**Request body** is the client's `ChatMessage` model:
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"id": "<uuid>",
|
||
|
|
"connectionID": "<uuid>",
|
||
|
|
"senderID": "<opaque-user-id>",
|
||
|
|
"ciphertext": "<base64 ChaChaPoly sealed box>",
|
||
|
|
"sentAt": "…"
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**Server:**
|
||
|
|
- Validate the sender is part of the `connectionID`.
|
||
|
|
- Store/relay the ciphertext only. **Never decrypt.**
|
||
|
|
- Push a **`message`** event to the recipient's socket in real time.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 6. Real-time WebSocket
|
||
|
|
|
||
|
|
**Endpoint:** `wss://api.proximity.app/ws`
|
||
|
|
**Auth:** `Authorization: Bearer <proximity-session-token>`
|
||
|
|
|
||
|
|
The client connects when the user goes **present** and disconnects when they
|
||
|
|
leave. The server must route events to the correct socket via the token
|
||
|
|
registration from `/v1/token`.
|
||
|
|
|
||
|
|
### 6.1 Server → Client events
|
||
|
|
|
||
|
|
The client's `RealtimeClient` parses this envelope:
|
||
|
|
```json
|
||
|
|
{ "type": "mutual", "remoteToken": "<token>" }
|
||
|
|
{ "type": "message", "message": { …ChatMessage… } }
|
||
|
|
```
|
||
|
|
|
||
|
|
**`mutual`** — the other party waved back. The client matches `remoteToken`
|
||
|
|
against its waiting encounter and advances to the reveal.
|
||
|
|
|
||
|
|
**`message`** — a new encrypted message. The client decrypts and appends.
|
||
|
|
|
||
|
|
### 6.2 Client → Server events (optional)
|
||
|
|
The client may send `ping`/JSON keepalives; the server should respond with
|
||
|
|
`{ "type": "pong" }`. The server must tolerate silent clients and drop stale
|
||
|
|
socket→token mappings.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 7. Data Model (server-side)
|
||
|
|
|
||
|
|
| Table | Fields | Notes |
|
||
|
|
|---|---|---|
|
||
|
|
| `accounts` | id, provider, display_name, email, created_at | account identity |
|
||
|
|
| `sessions` | token, account_id, expires_at | bearer sessions |
|
||
|
|
| `presence` | token, account_id, tier, geohash, socket_id, updated_at | rotating anon presence |
|
||
|
|
| `encounters` | id, token_a, token_b, tier, engine, created_at | mutual proximity record |
|
||
|
|
| `waves` | from_token, to_token, created_at | directional interest edges |
|
||
|
|
| `keys` | token, public_key | E2E public keys (mutual only) |
|
||
|
|
| `connections` | id, user_a, user_b, created_at | mutual reveals |
|
||
|
|
| `messages` | id, connection_id, sender_id, ciphertext, created_at | ciphertext only |
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 8. Security & Privacy Checklist
|
||
|
|
|
||
|
|
- [ ] Provider tokens verified server-side (never trust client).
|
||
|
|
- [ ] Session tokens short-lived + revocable.
|
||
|
|
- [ ] Presence tokens rotate client-side; server drops stale ones.
|
||
|
|
- [ ] `waves` are directional; no reveal without mutual consent.
|
||
|
|
- [ ] `peer-key` returned only within a mutual relationship.
|
||
|
|
- [ ] Messages stored as ciphertext only; no decryption server-side.
|
||
|
|
- [ ] `block` is permanent and bidirectional.
|
||
|
|
- [ ] Right-to-be-forgotten: deleting an account purges all rows.
|
||
|
|
- [ ] No precise location history retained by default.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 9. Suggested Tech Stack
|
||
|
|
|
||
|
|
- **Language:** Go or Node.js (TypeScript) — good WebSocket support.
|
||
|
|
- **Transport:** HTTPS + WebSocket (single origin to share auth).
|
||
|
|
- **Persistence:** PostgreSQL (encounters/waves/messages) + Redis (presence
|
||
|
|
sockets, ephemeral token→socket mapping).
|
||
|
|
- **Push:** APNs for offline wake-ups (best-effort; presence is foreground).
|