[feat](alarm): let devices subscribe without an account, with a token this service mints
Receiving an alarm should not require signing up, and it should not require pretending to be a user either: an anonymous subscriber is not an account, it is a subscription. The row is the identity. joinAlert now takes NO access token. The subscribe token from the QR is the authorisation, and the service answers with a deviceToken (32 CSPRNG bytes, base64url, stored only as SHA-256) returned exactly once. Later subscriber calls send it as X-Device-Token (?deviceToken= on the WebSocket upgrade, which cannot set headers): getAlert, alarmStatus, ackAlarm, removeSubscription, qrImage and GET /ws/alarm. The credential is deliberately narrow. It names one subscription on one alert, so an ack addresses the ledger row directly and 'am I subscribed' is a field comparison. createAlert/triggerAlert/disarmAlert/deleteAlert/listSubscriptions never see it, so they keep answering 401 for a subscriber without any new gate: owning an alert still needs an account, and the admin token stays the only secret that can ring or silence one. Re-joining from the same device rotates the token (one row per alert+device), so a lost credential is recoverable with the QR and a leaked one dies on the next join. ALARM_ANONYMOUS=false puts joinAlert back behind login and stores the subscription against the account, exactly like rev 9. Migration 010 is additive and idempotent: subscriber_user_id becomes nullable, device_token_hash gets a unique index, and alarm_events.actor_user_id admits NULL because an anonymous join logs an actor-less event. Verified through Maestro against the dev stack (full table in planning/platform/anonymous-access/plan.md): join with no token -> 200 and the auth.users count does not move; getAlert/alarmStatus/ackAlarm with the token; trigger -> 409 with the frame -> ack acked:1 destroyed:true; WS 101 with both credentials (403 wrong subscribe token, 401 bad device token); old token 401 after a re-join; admin actions 401.
This commit is contained in:
@@ -213,7 +213,7 @@ alert shapes documented here).
|
||||
| --- | --- | --- | --- |
|
||||
| `me` | GET | `GET /auth/me` | — (access token header only) |
|
||||
| `logout` | GET, POST | `POST /auth/logout` | POST: `{ "refreshToken": "…" }`; GET: bodyless no-op |
|
||||
| `login` | POST | `POST /auth/login` | `{ "username": "…", "password": "…" }` |
|
||||
| `login` | POST | `POST /auth/login` | `{ "email": "…", "password": "…" }` (the hub identifies accounts by email) |
|
||||
| `register` | POST | `POST /auth/register` | `{ "username": "…", "password": "…", "email": "…" }` |
|
||||
| `refresh` | POST | `POST /auth/refresh` | `{ "refreshToken": "…" }` |
|
||||
| `delete` | POST | `POST /auth/delete` | `{ "password": "…" }` |
|
||||
@@ -222,6 +222,36 @@ Direct `/auth/*` routes exist for the same calls; the RN clients call those for
|
||||
login/register (raw body, no envelope). These `/action/` variants are the
|
||||
envelope-wrapped form of the same operations.
|
||||
|
||||
### Anonymous subscribers (rev 10)
|
||||
|
||||
Receiving alarms needs no account. There is **no subscriber account, synthetic
|
||||
or otherwise**: the subscription row is the identity, and the service mints a
|
||||
device token for it at join time.
|
||||
|
||||
1. `joinAlert` is called with **no `X-Access-Token`** — the shared subscribe
|
||||
token from the QR is the only proof required. The response carries
|
||||
`deviceToken`, returned **once**, on the join that created or rotated it.
|
||||
2. Every later subscriber call sends that token as `X-Device-Token`
|
||||
(`?deviceToken=` for the WebSocket upgrade, which cannot set headers):
|
||||
`getAlert`, `alarmStatus`, `ackAlarm`, `removeSubscription`, `qrImage` and
|
||||
`GET /ws/alarm`.
|
||||
3. The token is scoped to exactly one alert and one subscription. It can read
|
||||
that alert, ring/dismiss its own alarm and drop its own row — nothing else.
|
||||
`createAlert`, `triggerAlert`, `disarmAlert`, `deleteAlert` and
|
||||
`listSubscriptions` require an access token (`401 ERR_UNAUTHORIZED`);
|
||||
owning an alert always needs an account.
|
||||
4. Re-joining from the same device **rotates** the token: the old one stops
|
||||
working immediately, which is how a lost token is recovered with the
|
||||
subscribe token alone. A device is one subscription per alert.
|
||||
5. `ALARM_ANONYMOUS=false` (env, default on) turns the unauthenticated path off:
|
||||
`joinAlert` then requires an access token and stores the subscription against
|
||||
that user, exactly like rev 9.
|
||||
|
||||
The token is 32 CSPRNG bytes (base64url), stored as plain SHA-256 (there is
|
||||
nothing to stretch); the database never holds the token itself. No `auth.users`
|
||||
row is created, so nothing outside `alarm.subscriptions` changes when a device
|
||||
subscribes.
|
||||
|
||||
---
|
||||
|
||||
## `createAlert`
|
||||
@@ -320,7 +350,11 @@ Errors: `ERR_VALIDATION`, `ERR_ALERT_NOT_FOUND`, `ERR_ALERT_NOT_SUBSCRIBED`,
|
||||
|
||||
Subscribes the caller's device. This is what the QR scan calls.
|
||||
|
||||
**POST** `/action/joinAlert`
|
||||
**POST** `/action/joinAlert` — **no access token required** (rev 10). The shared
|
||||
subscribe token is the authorisation; the response includes a `deviceToken`
|
||||
(returned once) that authenticates this device on its later subscriber calls.
|
||||
With `ALARM_ANONYMOUS=false` the call requires `X-Access-Token` and no device
|
||||
token is issued.
|
||||
|
||||
```json
|
||||
{ "payload": {
|
||||
@@ -727,6 +761,7 @@ The service path is:
|
||||
|
||||
```
|
||||
/ws/alarm?alertId=<uuid>&token=<adminToken|subscribeToken>&accessToken=<token>[&deviceId=<id>]
|
||||
/ws/alarm?alertId=<uuid>&token=<subscribeToken>&deviceToken=<deviceToken>
|
||||
```
|
||||
|
||||
Through an instance (the supported path) that is
|
||||
@@ -735,8 +770,11 @@ Through an instance (the supported path) that is
|
||||
end, so the 20s ping / 60s pong accounting below is measured between client and
|
||||
service, not against the proxy.
|
||||
|
||||
The socket is **authenticated, not anonymous**: the access token identifies the
|
||||
user and the second token proves the relationship to the alert.
|
||||
The socket is **authenticated, not anonymous**: one of two credentials
|
||||
identifies the subscriber — an access token (an account) or a device token
|
||||
(rev 10, a device that joined without an account; sent as `?deviceToken=` because
|
||||
the upgrade cannot set headers) — and `token` proves the relationship to the
|
||||
alert in both cases.
|
||||
|
||||
### Authentication
|
||||
|
||||
|
||||
Reference in New Issue
Block a user