180 lines
5.1 KiB
Markdown
180 lines
5.1 KiB
Markdown
# Socket Broker
|
|
|
|
A Socket.IO broker with topic-based messaging for ComedyKit. Backend services publish over an HTTP API protected by an API key; browsers and mobile clients authenticate with short-lived, topic-scoped tokens issued by Laravel.
|
|
|
|
## Features
|
|
|
|
- Socket.IO server for real-time client subscriptions.
|
|
- HTTP API for backend publishing.
|
|
- API-key authentication for publishers.
|
|
- HMAC-SHA256 client tokens for connection and topic authorization.
|
|
- Room-based topic delivery (no in-memory subscription maps).
|
|
- Protected topic introspection and public health check.
|
|
- CORS allowlist, subscription limits, and subscribe rate limiting.
|
|
|
|
## Architecture
|
|
|
|
```
|
|
Frontend / mobile clients
|
|
│ Socket.IO (connection token + topic token)
|
|
▼
|
|
Socket broker ── rooms: user/{uuid}/notifications
|
|
▲ event/{slug}/ticket_statistics
|
|
│ POST /publish (API key)
|
|
Laravel backend
|
|
```
|
|
|
|
## Configuration
|
|
|
|
Copy `env.example` to `.env`:
|
|
|
|
| Variable | Default | Description |
|
|
| --- | --- | --- |
|
|
| `WS_PORT` | `3000` | Socket.IO / HTTP port |
|
|
| `API_PORT` | `WS_PORT` | Optional second HTTP API port |
|
|
| `API_KEY` | `default-api-key-change-me` | Publisher API key; never expose to clients |
|
|
| `SOCKET_TOKEN_SECRET` | — | Shared HMAC secret; must match Laravel `SOCKET_TOKEN_SECRET` |
|
|
| `AUTH_MODE` | `required` | `off`, `optional` (rollout only), or `required` |
|
|
| `ALLOWED_ORIGINS` | `https://*.comedykit.be,...` | Comma-separated origins; supports `*.domain.tld` |
|
|
| `MAX_TOPICS_PER_SOCKET` | `20` | Subscription cap per connection |
|
|
| `SUBSCRIBE_RATE_LIMIT` | `30` | Subscribe attempts per 10 seconds per connection |
|
|
|
|
`AUTH_MODE=required` refuses to start without `SOCKET_TOKEN_SECRET`.
|
|
|
|
### Rollout
|
|
|
|
1. Deploy with `AUTH_MODE=optional` so legacy clients still work.
|
|
2. Deploy Laravel with `/notifications/socket-token` and the new publisher job.
|
|
3. Deploy the updated web/mobile clients.
|
|
4. Switch to `AUTH_MODE=required`.
|
|
|
|
## Client authentication
|
|
|
|
Laravel issues two token types:
|
|
|
|
```json
|
|
// Connection token
|
|
{ "v": 1, "sub": "<user uuid>", "typ": "connect", "iat": 0, "exp": 0, "jti": "..." }
|
|
|
|
// Topic token
|
|
{ "v": 1, "sub": "<user uuid>", "typ": "topic", "topic": "event/slug/ticket_statistics", "iat": 0, "exp": 0, "jti": "..." }
|
|
```
|
|
|
|
Tokens are encoded as:
|
|
|
|
```
|
|
base64url(JSON payload) + "." + base64url(HMAC-SHA256(payload, SOCKET_TOKEN_SECRET))
|
|
```
|
|
|
|
Clients connect with the connection token:
|
|
|
|
```javascript
|
|
const socket = io("https://bram-sockets.comedykit.be", {
|
|
auth: { token: connectionToken },
|
|
});
|
|
```
|
|
|
|
A user may always subscribe to `user/{their-own-id}/notifications`. Any other topic requires a topic token whose `sub` matches the connection subject and whose `topic` matches the subscription exactly.
|
|
|
|
## WebSocket API
|
|
|
|
### Subscribe
|
|
|
|
```javascript
|
|
socket.emit("subscribe", "user/<uuid>/notifications");
|
|
|
|
// Or, for a topic-scoped token:
|
|
socket.emit("subscribe", {
|
|
topic: "event/my-event/ticket_statistics",
|
|
token: topicToken,
|
|
});
|
|
|
|
socket.on("subscribed", data => {
|
|
console.log(`Subscribed to ${data.topic}`);
|
|
});
|
|
```
|
|
|
|
### Unsubscribe
|
|
|
|
```javascript
|
|
socket.emit("unsubscribe", "user/<uuid>/notifications");
|
|
socket.on("unsubscribed", data => {
|
|
console.log(`Unsubscribed from ${data.topic}`);
|
|
});
|
|
```
|
|
|
|
### Receive messages
|
|
|
|
```javascript
|
|
socket.on("message", data => {
|
|
console.log(data.topic, data.message, data.data, data.timestamp);
|
|
});
|
|
```
|
|
|
|
### Errors
|
|
|
|
```javascript
|
|
socket.on("error", error => {
|
|
// INVALID_TOPIC | FORBIDDEN_TOPIC | RATE_LIMITED | TOPIC_LIMIT
|
|
console.error(error.code, error.message);
|
|
});
|
|
```
|
|
|
|
## HTTP API
|
|
|
|
### Public
|
|
|
|
- `GET /health` — status, connection count, auth mode. Does not expose topic names.
|
|
|
|
### Protected (API key)
|
|
|
|
- `GET /topics`
|
|
- `GET /topics/:topic`
|
|
- `POST /publish`
|
|
- `POST /publish/:topic`
|
|
|
|
API key is accepted as `Authorization: Bearer <key>`, `x-api-key`, or `api_key` query parameter.
|
|
|
|
#### Publish
|
|
|
|
```bash
|
|
curl -X POST http://localhost:3000/publish \
|
|
-H "x-api-key: your-api-key" \
|
|
-H "Content-Type: application/json" \
|
|
-d '{"topic":"user/<uuid>/notifications","message":"{\"id\":\"...\"}"}'
|
|
```
|
|
|
|
Response:
|
|
|
|
- `200` with `{ success, message, subscribers, payload }`
|
|
- `204` when the topic has no subscribers
|
|
- `400` invalid request
|
|
- `401` invalid or missing API key
|
|
|
|
## Development
|
|
|
|
```bash
|
|
npm install
|
|
npm test
|
|
AUTH_MODE=optional API_KEY=dev-key SOCKET_TOKEN_SECRET=dev-secret npm start
|
|
```
|
|
|
|
## Docker
|
|
|
|
```bash
|
|
cp env.example .env
|
|
# set API_KEY, SOCKET_TOKEN_SECRET, AUTH_MODE, ALLOWED_ORIGINS
|
|
docker compose up -d
|
|
```
|
|
|
|
The Docker image runs as a non-root user and exposes port 3000. If `API_PORT` differs from `WS_PORT`, a second HTTP server is started on that port.
|
|
|
|
## Security notes
|
|
|
|
- `API_KEY` is publisher-only. Never ship it to clients.
|
|
- `SOCKET_TOKEN_SECRET` must be at least 32 random bytes and identical in Laravel and the broker.
|
|
- Tokens are short-lived. The broker verifies them when a subscription is created; clients re-fetch tokens on reconnect.
|
|
- Use HTTPS/WSS in production.
|
|
- Rotate secrets by coordinating Laravel and broker deployments; outstanding tokens become invalid.
|
|
- `AUTH_MODE=optional` or `off` is for local development or the rollout window only.
|