[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) |
|
| `me` | GET | `GET /auth/me` | — (access token header only) |
|
||||||
| `logout` | GET, POST | `POST /auth/logout` | POST: `{ "refreshToken": "…" }`; GET: bodyless no-op |
|
| `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": "…" }` |
|
| `register` | POST | `POST /auth/register` | `{ "username": "…", "password": "…", "email": "…" }` |
|
||||||
| `refresh` | POST | `POST /auth/refresh` | `{ "refreshToken": "…" }` |
|
| `refresh` | POST | `POST /auth/refresh` | `{ "refreshToken": "…" }` |
|
||||||
| `delete` | POST | `POST /auth/delete` | `{ "password": "…" }` |
|
| `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
|
login/register (raw body, no envelope). These `/action/` variants are the
|
||||||
envelope-wrapped form of the same operations.
|
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`
|
## `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.
|
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
|
```json
|
||||||
{ "payload": {
|
{ "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=<adminToken|subscribeToken>&accessToken=<token>[&deviceId=<id>]
|
||||||
|
/ws/alarm?alertId=<uuid>&token=<subscribeToken>&deviceToken=<deviceToken>
|
||||||
```
|
```
|
||||||
|
|
||||||
Through an instance (the supported path) that is
|
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
|
end, so the 20s ping / 60s pong accounting below is measured between client and
|
||||||
service, not against the proxy.
|
service, not against the proxy.
|
||||||
|
|
||||||
The socket is **authenticated, not anonymous**: the access token identifies the
|
The socket is **authenticated, not anonymous**: one of two credentials
|
||||||
user and the second token proves the relationship to the alert.
|
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
|
### Authentication
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
-- Copyright (C) 2026 Dcntrlized
|
||||||
|
--
|
||||||
|
-- This program is free software: you can redistribute it and/or modify
|
||||||
|
-- it under the terms of the GNU Affero General Public License as published
|
||||||
|
-- by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
-- (at your option) any later version.
|
||||||
|
--
|
||||||
|
-- This program is distributed in the hope that it will be useful,
|
||||||
|
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
-- GNU Affero General Public License for more details.
|
||||||
|
--
|
||||||
|
-- You should have received a copy of the GNU Affero General Public License
|
||||||
|
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
-- 010_device_tokens.sql — subscriber authentication without an account.
|
||||||
|
--
|
||||||
|
-- Contract rev 10. A subscriber is not a user: the subscription row IS the
|
||||||
|
-- identity, and a device token minted by THIS service at join time
|
||||||
|
-- authenticates it. No auth.users row, no synthetic email, no hub round-trip —
|
||||||
|
-- an unauthenticated path, not a fake account.
|
||||||
|
--
|
||||||
|
-- What the token proves is narrow and sufficient: "this device is subscribed to
|
||||||
|
-- this alert". It authorises reading the alert's public shape, the alarm
|
||||||
|
-- check-in, acking its own debt and removing its own subscription. It can never
|
||||||
|
-- create, trigger, disarm or delete an alert — those need the admin token, which
|
||||||
|
-- a subscriber is never given.
|
||||||
|
--
|
||||||
|
-- Additive and idempotent: accounts keep working exactly as before (a logged-in
|
||||||
|
-- user's join still records subscriber_user_id and needs no device token).
|
||||||
|
\c dcntrlized
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'alarm') THEN
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- A device identity has no account behind it.
|
||||||
|
ALTER TABLE alarm.subscriptions
|
||||||
|
ALTER COLUMN subscriber_user_id DROP NOT NULL;
|
||||||
|
|
||||||
|
-- SHA-256 (hex) of the device token. Never the token itself: a database
|
||||||
|
-- read must not hand out a credential. NULL for account-backed rows.
|
||||||
|
ALTER TABLE alarm.subscriptions
|
||||||
|
ADD COLUMN IF NOT EXISTS device_token_hash CHAR(64);
|
||||||
|
|
||||||
|
-- The event log records who acted. An anonymous subscriber may act (joining
|
||||||
|
-- is logged), and there is no user id to write, so the column must admit
|
||||||
|
-- NULL. Trigger/disarm stay account actions and always carry an actor.
|
||||||
|
ALTER TABLE alarm.alarm_events
|
||||||
|
ALTER COLUMN actor_user_id DROP NOT NULL;
|
||||||
|
|
||||||
|
-- One subscription per device token: the hash is the lookup key on every
|
||||||
|
-- subscriber request, so a collision would be ambiguous by construction.
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS subscriptions_device_token_idx
|
||||||
|
ON alarm.subscriptions (device_token_hash)
|
||||||
|
WHERE device_token_hash IS NOT NULL;
|
||||||
|
|
||||||
|
-- One device is one subscription per alert, whichever path created it. The
|
||||||
|
-- pre-rev-10 unique key included the user id, so existing data cannot carry
|
||||||
|
-- duplicates in practice; the guard keeps a surprise from failing a
|
||||||
|
-- migration on a live instance (the join path rotates rather than inserts).
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM alarm.subscriptions
|
||||||
|
GROUP BY alert_id, device_id
|
||||||
|
HAVING count(*) > 1
|
||||||
|
) THEN
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS subscriptions_alert_device_idx
|
||||||
|
ON alarm.subscriptions (alert_id, device_id);
|
||||||
|
ELSE
|
||||||
|
RAISE NOTICE 'alarm.subscriptions has duplicate (alert_id, device_id) rows; skipping the unique index';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
+52
-19
@@ -54,7 +54,7 @@ type ackAlarmResponse struct {
|
|||||||
// alert no longer exists, which is the intended outcome, not a failure.
|
// alert no longer exists, which is the intended outcome, not a failure.
|
||||||
func AckAlarm(w http.ResponseWriter, r *http.Request, payload any) {
|
func AckAlarm(w http.ResponseWriter, r *http.Request, payload any) {
|
||||||
const action = "ackAlarm"
|
const action = "ackAlarm"
|
||||||
p, ok := principal(w, r, action)
|
c, ok := callerFor(w, r, action)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -79,34 +79,67 @@ func AckAlarm(w http.ResponseWriter, r *http.Request, payload any) {
|
|||||||
dbError(w, action, err)
|
dbError(w, action, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
allowed, err := isAdminOrSubscriber(r, alert, p.ID)
|
if c.IsDevice {
|
||||||
if err != nil {
|
if !c.subscribedTo(alertID) {
|
||||||
dbError(w, action, err)
|
writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "not subscribed to this alert")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !allowed {
|
} else {
|
||||||
writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "not subscribed to this alert")
|
allowed, err := isAdminOrSubscriber(r, alert, c.UserID)
|
||||||
return
|
if err != nil {
|
||||||
|
dbError(w, action, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !allowed {
|
||||||
|
writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "not subscribed to this alert")
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
resp := ackAlarmResponse{AlertID: alertID.String(), EventID: eventID.String()}
|
resp := ackAlarmResponse{AlertID: alertID.String(), EventID: eventID.String()}
|
||||||
|
|
||||||
// The owner has no delivery row (only subscribers are owed alarms), so an
|
// A device token lives on the subscription, so the ledger row is addressed
|
||||||
// owner ack is a well-formed no-op rather than a validation error.
|
// directly: no user id, no device-id echo, nothing to spoof.
|
||||||
if alert.AdminUserID != p.ID {
|
if c.IsDevice {
|
||||||
raw, err := validateDeviceID(fieldOrQuery(r, payload, "deviceId"))
|
cleared, err := db.AckDelivery(r.Context(), alertID, c.Sub.ID, eventID)
|
||||||
if err != nil {
|
|
||||||
validationError(w, "%v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
cleared, err := db.AckAlarmDelivery(r.Context(), alertID, p.ID, deviceid.Hash(raw), eventID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
dbError(w, action, err)
|
dbError(w, action, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
resp.Acked = cleared
|
resp.Acked = cleared
|
||||||
if cleared > 0 {
|
if cleared > 0 {
|
||||||
if err := db.RecordDelivered(r.Context(), alertID, p.ID); err != nil {
|
if err := db.TouchSubscription(r.Context(), c.Sub.ID); err != nil {
|
||||||
|
dbError(w, action, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
destroyed, err := db.CompleteAlertDeliveries(r.Context(), alertID)
|
||||||
|
if err != nil {
|
||||||
|
dbError(w, action, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp.Destroyed = destroyed
|
||||||
|
}
|
||||||
|
Debug(action, "POST", "alert=%s event=%s acked=%d destroyed=%t (device)", alertID, eventID, resp.Acked, resp.Destroyed)
|
||||||
|
writeJSON(w, http.StatusOK, resp)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// The owner has no delivery row (only subscribers are owed alarms), so an
|
||||||
|
// owner ack is a well-formed no-op rather than a validation error.
|
||||||
|
if alert.AdminUserID != c.UserID {
|
||||||
|
raw, err := validateDeviceID(fieldOrQuery(r, payload, "deviceId"))
|
||||||
|
if err != nil {
|
||||||
|
validationError(w, "%v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cleared, err := db.AckAlarmDelivery(r.Context(), alertID, c.UserID, deviceid.Hash(raw), eventID)
|
||||||
|
if err != nil {
|
||||||
|
dbError(w, action, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp.Acked = cleared
|
||||||
|
if cleared > 0 {
|
||||||
|
if err := db.RecordDelivered(r.Context(), alertID, c.UserID); err != nil {
|
||||||
dbError(w, action, err)
|
dbError(w, action, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-10
@@ -281,7 +281,7 @@ func CreateAlert(w http.ResponseWriter, r *http.Request, payload any) {
|
|||||||
// admin and by any subscribed user.
|
// admin and by any subscribed user.
|
||||||
func GetAlert(w http.ResponseWriter, r *http.Request, payload any) {
|
func GetAlert(w http.ResponseWriter, r *http.Request, payload any) {
|
||||||
const action = "getAlert"
|
const action = "getAlert"
|
||||||
p, ok := principal(w, r, action)
|
c, ok := callerFor(w, r, action)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -296,16 +296,25 @@ func GetAlert(w http.ResponseWriter, r *http.Request, payload any) {
|
|||||||
dbError(w, action, err)
|
dbError(w, action, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
isAdmin := res.Alert.AdminUserID == p.ID
|
isAdmin := !c.IsDevice && res.Alert.AdminUserID == c.UserID
|
||||||
if !isAdmin {
|
if !isAdmin {
|
||||||
allowed, err := db.IsSubscriber(r.Context(), alertID, p.ID)
|
if c.IsDevice {
|
||||||
if err != nil {
|
// The device token names exactly one subscription, so "subscribed"
|
||||||
dbError(w, action, err)
|
// is a field comparison rather than a query.
|
||||||
return
|
if !c.subscribedTo(alertID) {
|
||||||
}
|
writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "not subscribed to this alert")
|
||||||
if !allowed {
|
return
|
||||||
writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "not subscribed to this alert")
|
}
|
||||||
return
|
} else {
|
||||||
|
allowed, err := db.IsSubscriber(r.Context(), alertID, c.UserID)
|
||||||
|
if err != nil {
|
||||||
|
dbError(w, action, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !allowed {
|
||||||
|
writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "not subscribed to this alert")
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
count, err := db.CountSubscriptions(r.Context(), alertID)
|
count, err := db.CountSubscriptions(r.Context(), alertID)
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
// Copyright (C) 2026 Dcntrlized
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published
|
||||||
|
// by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package actions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.dcntrlized.org/Dcntrlized/Alarm/db"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// rev 10: an anonymous subscriber is a device credential, never a fake user.
|
||||||
|
|
||||||
|
func TestMintDeviceTokenIsRandomAndURLSafe(t *testing.T) {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for i := 0; i < 64; i++ {
|
||||||
|
tok, err := mintDeviceToken()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("mint: %v", err)
|
||||||
|
}
|
||||||
|
if len(tok) != 43 {
|
||||||
|
t.Fatalf("token length = %d, want 43 (32 bytes base64url)", len(tok))
|
||||||
|
}
|
||||||
|
if strings.ContainsAny(tok, "+/=") {
|
||||||
|
t.Fatalf("token %q is not base64url", tok)
|
||||||
|
}
|
||||||
|
if seen[tok] {
|
||||||
|
t.Fatalf("mint repeated a token")
|
||||||
|
}
|
||||||
|
seen[tok] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHashDeviceTokenIsStableSHA256Hex(t *testing.T) {
|
||||||
|
const tok = "device-token-under-test"
|
||||||
|
first := hashDeviceToken(tok)
|
||||||
|
if len(first) != 64 {
|
||||||
|
t.Fatalf("hash length = %d, want 64 hex", len(first))
|
||||||
|
}
|
||||||
|
if first != hashDeviceToken(tok) {
|
||||||
|
t.Fatalf("hash is not deterministic")
|
||||||
|
}
|
||||||
|
if first == hashDeviceToken(tok+"x") {
|
||||||
|
t.Fatalf("different tokens hashed the same")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnonymousEnabledSwitch(t *testing.T) {
|
||||||
|
cases := map[string]bool{
|
||||||
|
"": true,
|
||||||
|
"true": true,
|
||||||
|
"1": true,
|
||||||
|
"yes": true,
|
||||||
|
"on": true,
|
||||||
|
"false": false,
|
||||||
|
"0": false,
|
||||||
|
"no": false,
|
||||||
|
"off": false,
|
||||||
|
" OFF ": false,
|
||||||
|
"maybe": true,
|
||||||
|
}
|
||||||
|
for value, want := range cases {
|
||||||
|
t.Setenv("ALARM_ANONYMOUS", value)
|
||||||
|
if got := anonymousEnabled(); got != want {
|
||||||
|
t.Errorf("anonymousEnabled(%q) = %v, want %v", value, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeviceTokenReadsHeaderThenQuery(t *testing.T) {
|
||||||
|
r := httptest.NewRequest("GET", "/action/getAlert?deviceToken=from-query", nil)
|
||||||
|
if got := deviceToken(r); got != "from-query" {
|
||||||
|
t.Fatalf("query token = %q", got)
|
||||||
|
}
|
||||||
|
r = httptest.NewRequest("GET", "/action/getAlert?deviceToken=from-query", nil)
|
||||||
|
r.Header.Set(deviceTokenHeader, "from-header")
|
||||||
|
if got := deviceToken(r); got != "from-header" {
|
||||||
|
t.Fatalf("header token = %q", got)
|
||||||
|
}
|
||||||
|
r = httptest.NewRequest("GET", "/action/getAlert", nil)
|
||||||
|
if got := deviceToken(r); got != "" {
|
||||||
|
t.Fatalf("absent token = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCallerSubscribedToIsScopedToItsAlert(t *testing.T) {
|
||||||
|
alertID := uuid.New()
|
||||||
|
other := uuid.New()
|
||||||
|
|
||||||
|
device := &caller{IsDevice: true, Sub: &db.Subscription{AlertID: alertID}}
|
||||||
|
if !device.subscribedTo(alertID) {
|
||||||
|
t.Fatalf("device should be subscribed to its own alert")
|
||||||
|
}
|
||||||
|
if device.subscribedTo(other) {
|
||||||
|
t.Fatalf("a device token must not reach another alert")
|
||||||
|
}
|
||||||
|
|
||||||
|
// An account caller has no subscription object; the account check is done by
|
||||||
|
// the caller's own isAdminOrSubscriber path.
|
||||||
|
account := &caller{UserID: uuid.New()}
|
||||||
|
if account.subscribedTo(alertID) {
|
||||||
|
t.Fatalf("an account caller is not a device subscription")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJoinResponseCarriesTokenButNoUserIdForANonAccount(t *testing.T) {
|
||||||
|
alertID, subID := uuid.New(), uuid.New()
|
||||||
|
raw, err := json.Marshal(joinResponse{
|
||||||
|
subscriptionView: newSubscriptionView(&db.Subscription{
|
||||||
|
ID: subID, AlertID: alertID, DeviceID: "hashed",
|
||||||
|
}),
|
||||||
|
DeviceToken: "tok",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
var decoded map[string]any
|
||||||
|
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if decoded["deviceToken"] != "tok" {
|
||||||
|
t.Fatalf("deviceToken missing from join response: %s", raw)
|
||||||
|
}
|
||||||
|
if _, present := decoded["userId"]; present {
|
||||||
|
t.Fatalf("an anonymous subscription must not report a userId: %s", raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An account-backed subscription still reports it.
|
||||||
|
withUser := newSubscriptionView(&db.Subscription{
|
||||||
|
ID: subID, AlertID: alertID, SubscriberUserID: uuid.New(), DeviceID: "hashed",
|
||||||
|
})
|
||||||
|
if withUser.UserID == "" {
|
||||||
|
t.Fatalf("an account subscription must report its userId")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
// Copyright (C) 2026 Dcntrlized
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published
|
||||||
|
// by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package actions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.dcntrlized.org/Dcntrlized/Alarm/db"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// deviceTokenHeader carries the subscriber credential on every request that
|
||||||
|
// does not come from an account. The query fallback exists for the WebSocket
|
||||||
|
// upgrade, which cannot set headers.
|
||||||
|
const deviceTokenHeader = "X-Device-Token"
|
||||||
|
|
||||||
|
// caller is the resolved identity of a subscriber-side request. Exactly one of
|
||||||
|
// the two shapes is populated:
|
||||||
|
//
|
||||||
|
// account — UserID set, Sub nil (a logged-in user, rev 9 behaviour)
|
||||||
|
// device — Sub set, IsDevice true (an anonymous subscriber, rev 10)
|
||||||
|
type caller struct {
|
||||||
|
UserID uuid.UUID
|
||||||
|
Sub *db.Subscription
|
||||||
|
IsDevice bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// deviceToken reads the device credential from the header, then the query.
|
||||||
|
func deviceToken(r *http.Request) string {
|
||||||
|
if tok := strings.TrimSpace(r.Header.Get(deviceTokenHeader)); tok != "" {
|
||||||
|
return tok
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(r.URL.Query().Get("deviceToken"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// callerFor resolves the two credential kinds a subscriber-side action accepts.
|
||||||
|
// A device token is checked first: it is the cheaper path (one indexed lookup,
|
||||||
|
// no hub round-trip) and it must win when both are present, because a device
|
||||||
|
// credential is narrower than an account.
|
||||||
|
func callerFor(w http.ResponseWriter, r *http.Request, action string) (*caller, bool) {
|
||||||
|
if tok := deviceToken(r); tok != "" {
|
||||||
|
sub, err := db.GetSubscriptionByToken(r.Context(), hashDeviceToken(tok))
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, db.ErrNotFound) {
|
||||||
|
Warn(action, "auth", "unknown device token")
|
||||||
|
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "invalid device token")
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
dbError(w, action, err)
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return &caller{Sub: sub, IsDevice: true}, true
|
||||||
|
}
|
||||||
|
p, ok := principal(w, r, action)
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return &caller{UserID: p.ID}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// subscribedTo reports whether the caller may read the alert's subscriber
|
||||||
|
// surface. A device token is scoped to exactly one alert; an account is checked
|
||||||
|
// against the alert's admin and subscription rows by the caller.
|
||||||
|
func (c *caller) subscribedTo(alertID uuid.UUID) bool {
|
||||||
|
return c.IsDevice && c.Sub != nil && c.Sub.AlertID == alertID
|
||||||
|
}
|
||||||
|
|
||||||
|
// mintDeviceToken produces the 256-bit credential a device receives once, at
|
||||||
|
// join. base64url keeps it header-safe.
|
||||||
|
func mintDeviceToken() (string, error) {
|
||||||
|
buf := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return base64.RawURLEncoding.EncodeToString(buf), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// hashDeviceToken is the only transform applied to a device token: plain
|
||||||
|
// SHA-256 of 32 random bytes. The database never sees the token itself.
|
||||||
|
func hashDeviceToken(tok string) string {
|
||||||
|
sum := sha256.Sum256([]byte(tok))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -108,6 +109,17 @@ func principal(w http.ResponseWriter, r *http.Request, action string) (*authclie
|
|||||||
return p, true
|
return p, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// anonymousEnabled reports whether this instance accepts device-token joins.
|
||||||
|
// Default on; ALARM_ANONYMOUS=false/0/no/off makes joinAlert require an account
|
||||||
|
// again (subscriber-only access needs a login, exactly like rev 9).
|
||||||
|
func anonymousEnabled() bool {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(os.Getenv("ALARM_ANONYMOUS"))) {
|
||||||
|
case "false", "0", "no", "off":
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// decodePayload unmarshals the {payload} wrapper's inner value into dst.
|
// decodePayload unmarshals the {payload} wrapper's inner value into dst.
|
||||||
//
|
//
|
||||||
// Unknown fields are deliberately ignored: the clients are shipped separately
|
// Unknown fields are deliberately ignored: the clients are shipped separately
|
||||||
|
|||||||
+39
-17
@@ -187,7 +187,7 @@ type statusView struct {
|
|||||||
// background job is needed to expire anything.
|
// background job is needed to expire anything.
|
||||||
func AlarmStatus(w http.ResponseWriter, r *http.Request, payload any) {
|
func AlarmStatus(w http.ResponseWriter, r *http.Request, payload any) {
|
||||||
const action = "alarmStatus"
|
const action = "alarmStatus"
|
||||||
p, ok := principal(w, r, action)
|
c, ok := callerFor(w, r, action)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -202,14 +202,21 @@ func AlarmStatus(w http.ResponseWriter, r *http.Request, payload any) {
|
|||||||
dbError(w, action, err)
|
dbError(w, action, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
allowed, err := isAdminOrSubscriber(r, res.Alert, p.ID)
|
if c.IsDevice {
|
||||||
if err != nil {
|
if !c.subscribedTo(alertID) {
|
||||||
dbError(w, action, err)
|
writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "not subscribed to this alert")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !allowed {
|
} else {
|
||||||
writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "not subscribed to this alert")
|
allowed, err := isAdminOrSubscriber(r, res.Alert, c.UserID)
|
||||||
return
|
if err != nil {
|
||||||
|
dbError(w, action, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !allowed {
|
||||||
|
writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "not subscribed to this alert")
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Offline check-in, READ-ONLY. If this device still owes an alarm, answer
|
// Offline check-in, READ-ONLY. If this device still owes an alarm, answer
|
||||||
@@ -218,14 +225,29 @@ func AlarmStatus(w http.ResponseWriter, r *http.Request, payload any) {
|
|||||||
// 409 lost in transit must not forfeit the alarm: the next check-in has to
|
// 409 lost in transit must not forfeit the alarm: the next check-in has to
|
||||||
// find the same row. A trigger that has since been disarmed is not news, so
|
// find the same row. A trigger that has since been disarmed is not news, so
|
||||||
// that case falls through to the normal body (active:false).
|
// that case falls through to the normal body (active:false).
|
||||||
deviceHash := ""
|
var pending *db.PendingDelivery
|
||||||
if raw := strings.TrimSpace(r.URL.Query().Get("deviceId")); raw != "" {
|
if c.IsDevice {
|
||||||
deviceHash = deviceid.Hash(raw)
|
// The device token already identifies the subscription, so its whole
|
||||||
}
|
// pending set is the debt to report.
|
||||||
pending, err := db.PendingTrigger(r.Context(), alertID, p.ID, deviceHash)
|
rows, err := db.PendingReplay(r.Context(), alertID, c.Sub.ID)
|
||||||
if err != nil && !errors.Is(err, db.ErrNotFound) {
|
if err != nil && !errors.Is(err, db.ErrNotFound) {
|
||||||
dbError(w, action, err)
|
dbError(w, action, err)
|
||||||
return
|
return
|
||||||
|
}
|
||||||
|
if len(rows) > 0 {
|
||||||
|
pending = &rows[0]
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
deviceHash := ""
|
||||||
|
if raw := strings.TrimSpace(r.URL.Query().Get("deviceId")); raw != "" {
|
||||||
|
deviceHash = deviceid.Hash(raw)
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
pending, err = db.PendingTrigger(r.Context(), alertID, c.UserID, deviceHash)
|
||||||
|
if err != nil && !errors.Is(err, db.ErrNotFound) {
|
||||||
|
dbError(w, action, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if pending != nil && res.Event != nil && res.Event.Kind == alertstate.KindTrigger {
|
if pending != nil && res.Event != nil && res.Event.Kind == alertstate.KindTrigger {
|
||||||
writeOfflineAlarm(w, res.Alert, pending)
|
writeOfflineAlarm(w, res.Alert, pending)
|
||||||
|
|||||||
+16
-9
@@ -40,7 +40,7 @@ const (
|
|||||||
// through createAlert/qrPayload, the subscriber through the token they scanned).
|
// through createAlert/qrPayload, the subscriber through the token they scanned).
|
||||||
func QRImage(w http.ResponseWriter, r *http.Request, payload any) {
|
func QRImage(w http.ResponseWriter, r *http.Request, payload any) {
|
||||||
const action = "qrImage"
|
const action = "qrImage"
|
||||||
p, ok := principal(w, r, action)
|
c, ok := callerFor(w, r, action)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -55,14 +55,21 @@ func QRImage(w http.ResponseWriter, r *http.Request, payload any) {
|
|||||||
dbError(w, action, err)
|
dbError(w, action, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
allowed, err := isAdminOrSubscriber(r, alert, p.ID)
|
if c.IsDevice {
|
||||||
if err != nil {
|
if !c.subscribedTo(alertID) {
|
||||||
dbError(w, action, err)
|
writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "not subscribed to this alert")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !allowed {
|
} else {
|
||||||
writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "not subscribed to this alert")
|
allowed, err := isAdminOrSubscriber(r, alert, c.UserID)
|
||||||
return
|
if err != nil {
|
||||||
|
dbError(w, action, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !allowed {
|
||||||
|
writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "not subscribed to this alert")
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
scale, err := intQuery(r, "scale", defaultQRScale, 1, qr.MaxScale)
|
scale, err := intQuery(r, "scale", defaultQRScale, 1, qr.MaxScale)
|
||||||
|
|||||||
+104
-26
@@ -28,7 +28,7 @@ import (
|
|||||||
|
|
||||||
type subscriptionView struct {
|
type subscriptionView struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
UserID string `json:"userId"`
|
UserID string `json:"userId,omitempty"`
|
||||||
Username string `json:"username,omitempty"`
|
Username string `json:"username,omitempty"`
|
||||||
DeviceLabel string `json:"deviceLabel,omitempty"`
|
DeviceLabel string `json:"deviceLabel,omitempty"`
|
||||||
Platform string `json:"platform,omitempty"`
|
Platform string `json:"platform,omitempty"`
|
||||||
@@ -52,19 +52,33 @@ type subscriptionView struct {
|
|||||||
// schema, and the Alarm service does not read another service's tables. The
|
// schema, and the Alarm service does not read another service's tables. The
|
||||||
// field is omitted rather than sent empty.
|
// field is omitted rather than sent empty.
|
||||||
func newSubscriptionView(s *db.Subscription) subscriptionView {
|
func newSubscriptionView(s *db.Subscription) subscriptionView {
|
||||||
return subscriptionView{
|
view := subscriptionView{
|
||||||
ID: s.ID.String(),
|
ID: s.ID.String(),
|
||||||
UserID: s.SubscriberUserID.String(),
|
|
||||||
DeviceLabel: deref(s.DeviceLabel),
|
DeviceLabel: deref(s.DeviceLabel),
|
||||||
Platform: deref(s.Platform),
|
Platform: deref(s.Platform),
|
||||||
Enabled: s.Enabled,
|
Enabled: s.Enabled,
|
||||||
LastSeenAt: rfc3339Ptr(s.LastSeenAt),
|
LastSeenAt: rfc3339Ptr(s.LastSeenAt),
|
||||||
Created: rfc3339(s.Created),
|
Created: rfc3339(s.Created),
|
||||||
}
|
}
|
||||||
|
// An anonymous subscriber has no account behind it, so there is no user id
|
||||||
|
// to report (the zero uuid would be a lie, not an empty value).
|
||||||
|
if s.SubscriberUserID != uuid.Nil {
|
||||||
|
view.UserID = s.SubscriberUserID.String()
|
||||||
|
}
|
||||||
|
return view
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- joinAlert --------------------------------------------------------------
|
// ---- joinAlert --------------------------------------------------------------
|
||||||
|
|
||||||
|
// joinResponse is the subscription plus the device credential. The token is
|
||||||
|
// returned ONCE, on the call that created (or rotated) it; there is no way to
|
||||||
|
// fetch it again, which is why losing the app's storage means losing the
|
||||||
|
// subscriber identity.
|
||||||
|
type joinResponse struct {
|
||||||
|
subscriptionView
|
||||||
|
DeviceToken string `json:"deviceToken,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type joinRequest struct {
|
type joinRequest struct {
|
||||||
AlertID string `json:"alertId"`
|
AlertID string `json:"alertId"`
|
||||||
SubscribeToken string `json:"subscribeToken"`
|
SubscribeToken string `json:"subscribeToken"`
|
||||||
@@ -82,10 +96,6 @@ type joinRequest struct {
|
|||||||
// race, where two inserts for the same device hit the unique index at once.
|
// race, where two inserts for the same device hit the unique index at once.
|
||||||
func JoinAlert(w http.ResponseWriter, r *http.Request, payload any) {
|
func JoinAlert(w http.ResponseWriter, r *http.Request, payload any) {
|
||||||
const action = "joinAlert"
|
const action = "joinAlert"
|
||||||
p, ok := principal(w, r, action)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req joinRequest
|
var req joinRequest
|
||||||
if err := decodePayload(payload, &req); err != nil {
|
if err := decodePayload(payload, &req); err != nil {
|
||||||
@@ -126,34 +136,62 @@ func JoinAlert(w http.ResponseWriter, r *http.Request, payload any) {
|
|||||||
dbError(w, action, err)
|
dbError(w, action, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// The subscribe token is the only thing that authorises a join: it is the
|
||||||
|
// proof that the caller was shown the QR. No account, no device identity —
|
||||||
|
// this is the unauthenticated path, not a fake user.
|
||||||
if !alarmtoken.EqualToken(alert.SubscribeToken, strings.TrimSpace(req.SubscribeToken)) {
|
if !alarmtoken.EqualToken(alert.SubscribeToken, strings.TrimSpace(req.SubscribeToken)) {
|
||||||
Warn(action, "auth", "subscribe token mismatch alert=%s user=%s", alert.ID, p.ID)
|
Warn(action, "auth", "subscribe token mismatch alert=%s", alert.ID)
|
||||||
writeError(w, http.StatusForbidden, CodeAlertTokenInvalid, "invalid subscribe token")
|
writeError(w, http.StatusForbidden, CodeAlertTokenInvalid, "invalid subscribe token")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
existing, err := db.GetSubscriptionByDevice(r.Context(), alertID, p.ID, storedDeviceID)
|
// An operator can switch anonymous access off; then a join is an account
|
||||||
|
// action again and the subscription is owned by that user.
|
||||||
|
if !anonymousEnabled() {
|
||||||
|
joinAsAccount(w, r, action, alert, storedDeviceID, label, platform)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-join from a known device: rotate the credential instead of creating a
|
||||||
|
// second row. The old token stops working immediately, which is what makes
|
||||||
|
// a lost/leaked device token recoverable with the subscribe token alone.
|
||||||
|
existing, err := db.FindSubscriptionByDevice(r.Context(), alertID, storedDeviceID)
|
||||||
switch {
|
switch {
|
||||||
case err == nil:
|
case err == nil:
|
||||||
Debug(action, "POST", "idempotent re-join alert=%s sub=%s", alertID, existing.ID)
|
token, terr := mintDeviceToken()
|
||||||
writeJSON(w, http.StatusOK, newSubscriptionView(existing))
|
if terr != nil {
|
||||||
|
dbError(w, action, terr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := db.RotateDeviceToken(r.Context(), existing.ID, hashDeviceToken(token)); err != nil {
|
||||||
|
dbError(w, action, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Debug(action, "POST", "re-join alert=%s sub=%s (token rotated)", alertID, existing.ID)
|
||||||
|
writeJSON(w, http.StatusOK, joinResponse{newSubscriptionView(existing), token})
|
||||||
return
|
return
|
||||||
case !errors.Is(err, db.ErrNotFound):
|
case !errors.Is(err, db.ErrNotFound):
|
||||||
dbError(w, action, err)
|
dbError(w, action, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
token, err := mintDeviceToken()
|
||||||
|
if err != nil {
|
||||||
|
dbError(w, action, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tokenHash := hashDeviceToken(token)
|
||||||
sub, err := db.CreateSubscription(r.Context(), &db.Subscription{
|
sub, err := db.CreateSubscription(r.Context(), &db.Subscription{
|
||||||
AlertID: alertID,
|
AlertID: alertID,
|
||||||
SubscriberUserID: p.ID,
|
DeviceID: storedDeviceID,
|
||||||
DeviceID: storedDeviceID,
|
DeviceLabel: label,
|
||||||
DeviceLabel: label,
|
Platform: platform,
|
||||||
Platform: platform,
|
DeviceTokenHash: &tokenHash,
|
||||||
})
|
})
|
||||||
if errors.Is(err, db.ErrConflict) {
|
if errors.Is(err, db.ErrConflict) {
|
||||||
// Two concurrent joins for the same device: the sequential repeat path
|
// Two concurrent joins for the same device: the sequential repeat path
|
||||||
// above already handled the normal re-scan case.
|
// above already handled the normal re-scan case.
|
||||||
Warn(action, "POST", "concurrent duplicate join alert=%s user=%s", alertID, p.ID)
|
Warn(action, "POST", "concurrent duplicate join alert=%s", alertID)
|
||||||
writeError(w, http.StatusConflict, CodeAlertAlreadyJoined, "device is already subscribed to this alert")
|
writeError(w, http.StatusConflict, CodeAlertAlreadyJoined, "device is already subscribed to this alert")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -162,7 +200,42 @@ func JoinAlert(w http.ResponseWriter, r *http.Request, payload any) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
Debug(action, "POST", "joined alert=%s user=%s sub=%s", alertID, p.ID, sub.ID)
|
Debug(action, "POST", "joined alert=%s sub=%s (anonymous)", alertID, sub.ID)
|
||||||
|
writeJSON(w, http.StatusOK, joinResponse{newSubscriptionView(sub), token})
|
||||||
|
}
|
||||||
|
|
||||||
|
// joinAsAccount is the ALARM_ANONYMOUS=false path: the same join, but the
|
||||||
|
// caller must hold an access token and the row carries their user id.
|
||||||
|
func joinAsAccount(w http.ResponseWriter, r *http.Request, action string, alert *db.Alert,
|
||||||
|
deviceID string, label, platform *string) {
|
||||||
|
p, ok := principal(w, r, action)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
existing, err := db.GetSubscriptionByDevice(r.Context(), alert.ID, p.ID, deviceID)
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
writeJSON(w, http.StatusOK, newSubscriptionView(existing))
|
||||||
|
return
|
||||||
|
case !errors.Is(err, db.ErrNotFound):
|
||||||
|
dbError(w, action, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sub, err := db.CreateSubscription(r.Context(), &db.Subscription{
|
||||||
|
AlertID: alert.ID,
|
||||||
|
SubscriberUserID: p.ID,
|
||||||
|
DeviceID: deviceID,
|
||||||
|
DeviceLabel: label,
|
||||||
|
Platform: platform,
|
||||||
|
})
|
||||||
|
if errors.Is(err, db.ErrConflict) {
|
||||||
|
writeError(w, http.StatusConflict, CodeAlertAlreadyJoined, "device is already subscribed to this alert")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
dbError(w, action, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
writeJSON(w, http.StatusOK, newSubscriptionView(sub))
|
writeJSON(w, http.StatusOK, newSubscriptionView(sub))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,7 +276,7 @@ type removeSubscriptionRequest struct {
|
|||||||
// alert's admin may remove any device; a subscriber may remove their own.
|
// alert's admin may remove any device; a subscriber may remove their own.
|
||||||
func RemoveSubscription(w http.ResponseWriter, r *http.Request, payload any) {
|
func RemoveSubscription(w http.ResponseWriter, r *http.Request, payload any) {
|
||||||
const action = "removeSubscription"
|
const action = "removeSubscription"
|
||||||
p, ok := principal(w, r, action)
|
c, ok := callerFor(w, r, action)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -237,17 +310,22 @@ func RemoveSubscription(w http.ResponseWriter, r *http.Request, payload any) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if sub.SubscriberUserID != p.ID {
|
// A device token is scoped to its own subscription: it can remove that row
|
||||||
// Not the owner: only the alert admin may remove someone else's device.
|
// and nothing else. An account may remove its own device, or any device on
|
||||||
// A stranger gets the same code as a missing row so the endpoint never
|
// an alert it administers.
|
||||||
// confirms that the subscription exists.
|
if c.IsDevice {
|
||||||
|
if c.Sub.ID != sub.ID {
|
||||||
|
writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "subscription not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else if sub.SubscriberUserID != c.UserID {
|
||||||
alert, err := db.GetAlert(r.Context(), alertID)
|
alert, err := db.GetAlert(r.Context(), alertID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
dbError(w, action, err)
|
dbError(w, action, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if alert.AdminUserID != p.ID {
|
if alert.AdminUserID != c.UserID {
|
||||||
Warn(action, "auth", "user=%s may not remove sub=%s", p.ID, subscriptionID)
|
Warn(action, "auth", "user=%s may not remove sub=%s", c.UserID, subscriptionID)
|
||||||
writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "subscription not found")
|
writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "subscription not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -257,7 +335,7 @@ func RemoveSubscription(w http.ResponseWriter, r *http.Request, payload any) {
|
|||||||
dbError(w, action, err)
|
dbError(w, action, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
Debug(action, "POST", "removed sub=%s alert=%s by=%s", subscriptionID, alertID, p.ID)
|
Debug(action, "POST", "removed sub=%s alert=%s", subscriptionID, alertID)
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"alertId": alertID.String(),
|
"alertId": alertID.String(),
|
||||||
"subscriptionId": subscriptionID.String(),
|
"subscriptionId": subscriptionID.String(),
|
||||||
|
|||||||
+10
-4
@@ -27,9 +27,11 @@ import (
|
|||||||
|
|
||||||
// Event is one alarm.alarm_events row (append-only trigger/disarm log).
|
// Event is one alarm.alarm_events row (append-only trigger/disarm log).
|
||||||
type Event struct {
|
type Event struct {
|
||||||
ID uuid.UUID
|
ID uuid.UUID
|
||||||
AlertID uuid.UUID
|
AlertID uuid.UUID
|
||||||
Kind string
|
Kind string
|
||||||
|
// ActorUserID is uuid.Nil when the actor is an anonymous subscriber (a
|
||||||
|
// silent act such as a device joining); the column is nullable for that.
|
||||||
ActorUserID uuid.UUID
|
ActorUserID uuid.UUID
|
||||||
Message *string
|
Message *string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
@@ -39,7 +41,11 @@ const eventColumns = `id, alert_id, kind::text, actor_user_id, message, created_
|
|||||||
|
|
||||||
func scanEvent(row pgx.Row) (*Event, error) {
|
func scanEvent(row pgx.Row) (*Event, error) {
|
||||||
var e Event
|
var e Event
|
||||||
err := row.Scan(&e.ID, &e.AlertID, &e.Kind, &e.ActorUserID, &e.Message, &e.CreatedAt)
|
var actor *uuid.UUID
|
||||||
|
err := row.Scan(&e.ID, &e.AlertID, &e.Kind, &actor, &e.Message, &e.CreatedAt)
|
||||||
|
if actor != nil {
|
||||||
|
e.ActorUserID = *actor
|
||||||
|
}
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
}
|
}
|
||||||
|
|||||||
+63
-7
@@ -25,6 +25,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Subscription is one alarm.subscriptions row: a device that receives the alarm.
|
// Subscription is one alarm.subscriptions row: a device that receives the alarm.
|
||||||
|
//
|
||||||
|
// A subscription is the SUBSCRIBER IDENTITY (contract rev 10). Either it hangs
|
||||||
|
// off an account (`SubscriberUserID` set, the pre-rev-10 shape) or it stands
|
||||||
|
// alone with a device token the service minted at join (`DeviceTokenHash` set,
|
||||||
|
// `SubscriberUserID` is uuid.Nil). A subscriber is therefore never a fake user:
|
||||||
|
// there is no auth.users row behind an anonymous one.
|
||||||
type Subscription struct {
|
type Subscription struct {
|
||||||
ID uuid.UUID
|
ID uuid.UUID
|
||||||
AlertID uuid.UUID
|
AlertID uuid.UUID
|
||||||
@@ -35,15 +41,22 @@ type Subscription struct {
|
|||||||
Enabled bool
|
Enabled bool
|
||||||
LastSeenAt *time.Time
|
LastSeenAt *time.Time
|
||||||
Created time.Time
|
Created time.Time
|
||||||
|
// DeviceTokenHash is the SHA-256 of the token handed to the device once, at
|
||||||
|
// join. Nil for account-backed subscriptions.
|
||||||
|
DeviceTokenHash *string
|
||||||
}
|
}
|
||||||
|
|
||||||
const subscriptionColumns = `id, alert_id, subscriber_user_id, device_id, device_label,
|
const subscriptionColumns = `id, alert_id, subscriber_user_id, device_id, device_label,
|
||||||
platform, enabled, last_seen_at, created`
|
platform, enabled, last_seen_at, created, device_token_hash`
|
||||||
|
|
||||||
func scanSubscription(row pgx.Row) (*Subscription, error) {
|
func scanSubscription(row pgx.Row) (*Subscription, error) {
|
||||||
var s Subscription
|
var s Subscription
|
||||||
err := row.Scan(&s.ID, &s.AlertID, &s.SubscriberUserID, &s.DeviceID, &s.DeviceLabel,
|
var user *uuid.UUID
|
||||||
&s.Platform, &s.Enabled, &s.LastSeenAt, &s.Created)
|
err := row.Scan(&s.ID, &s.AlertID, &user, &s.DeviceID, &s.DeviceLabel,
|
||||||
|
&s.Platform, &s.Enabled, &s.LastSeenAt, &s.Created, &s.DeviceTokenHash)
|
||||||
|
if user != nil {
|
||||||
|
s.SubscriberUserID = *user
|
||||||
|
}
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
}
|
}
|
||||||
@@ -65,19 +78,24 @@ func CreateSubscription(ctx context.Context, s *Subscription) (*Subscription, er
|
|||||||
}
|
}
|
||||||
defer func() { _ = tx.Rollback(ctx) }()
|
defer func() { _ = tx.Rollback(ctx) }()
|
||||||
|
|
||||||
|
var actor *uuid.UUID
|
||||||
|
if s.SubscriberUserID != uuid.Nil {
|
||||||
|
actor = &s.SubscriberUserID
|
||||||
|
}
|
||||||
|
|
||||||
const q = `INSERT INTO alarm.subscriptions
|
const q = `INSERT INTO alarm.subscriptions
|
||||||
(alert_id, subscriber_user_id, device_id, device_label, platform)
|
(alert_id, subscriber_user_id, device_id, device_label, platform, device_token_hash)
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
RETURNING ` + subscriptionColumns
|
RETURNING ` + subscriptionColumns
|
||||||
row, err := scanSubscription(tx.QueryRow(ctx, q,
|
row, err := scanSubscription(tx.QueryRow(ctx, q,
|
||||||
s.AlertID, s.SubscriberUserID, s.DeviceID, s.DeviceLabel, s.Platform))
|
s.AlertID, actor, s.DeviceID, s.DeviceLabel, s.Platform, s.DeviceTokenHash))
|
||||||
if isUniqueViolation(err) {
|
if isUniqueViolation(err) {
|
||||||
return nil, ErrConflict
|
return nil, ErrConflict
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := recordEvent(ctx, tx, s.AlertID, &s.SubscriberUserID, EventJoined, ""); err != nil {
|
if err := recordEvent(ctx, tx, s.AlertID, actor, EventJoined, ""); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := tx.Commit(ctx); err != nil {
|
if err := tx.Commit(ctx); err != nil {
|
||||||
@@ -94,6 +112,44 @@ func GetSubscriptionByDevice(ctx context.Context, alertID, userID uuid.UUID, dev
|
|||||||
return scanSubscription(Pool().QueryRow(ctx, q, alertID, userID, deviceID))
|
return scanSubscription(Pool().QueryRow(ctx, q, alertID, userID, deviceID))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FindSubscriptionByDevice looks a subscription up by alert and device digest,
|
||||||
|
// whichever path created it. It is the idempotency lookup for an anonymous
|
||||||
|
// join, where there is no user id to key on.
|
||||||
|
func FindSubscriptionByDevice(ctx context.Context, alertID uuid.UUID, deviceID string) (*Subscription, error) {
|
||||||
|
const q = `SELECT ` + subscriptionColumns + `
|
||||||
|
FROM alarm.subscriptions
|
||||||
|
WHERE alert_id = $1 AND device_id = $2
|
||||||
|
ORDER BY created
|
||||||
|
LIMIT 1`
|
||||||
|
return scanSubscription(Pool().QueryRow(ctx, q, alertID, deviceID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSubscriptionByToken resolves the subscriber behind a device token. The
|
||||||
|
// token itself is never stored: the caller hashes what it received and this
|
||||||
|
// lookup runs against the unique index.
|
||||||
|
func GetSubscriptionByToken(ctx context.Context, tokenHash string) (*Subscription, error) {
|
||||||
|
const q = `SELECT ` + subscriptionColumns + `
|
||||||
|
FROM alarm.subscriptions
|
||||||
|
WHERE device_token_hash = $1`
|
||||||
|
return scanSubscription(Pool().QueryRow(ctx, q, tokenHash))
|
||||||
|
}
|
||||||
|
|
||||||
|
// RotateDeviceToken replaces the token hash of a subscription. A re-join from
|
||||||
|
// the same device rotates rather than inserts, so a lost token is recoverable
|
||||||
|
// with the subscribe token while the old one stops working immediately.
|
||||||
|
func RotateDeviceToken(ctx context.Context, subscriptionID uuid.UUID, tokenHash string) error {
|
||||||
|
tag, err := Pool().Exec(ctx,
|
||||||
|
`UPDATE alarm.subscriptions SET device_token_hash = $2 WHERE id = $1`,
|
||||||
|
subscriptionID, tokenHash)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetSubscription fetches one subscription scoped to its alert.
|
// GetSubscription fetches one subscription scoped to its alert.
|
||||||
func GetSubscription(ctx context.Context, alertID, subscriptionID uuid.UUID) (*Subscription, error) {
|
func GetSubscription(ctx context.Context, alertID, subscriptionID uuid.UUID) (*Subscription, error) {
|
||||||
const q = `SELECT ` + subscriptionColumns + `
|
const q = `SELECT ` + subscriptionColumns + `
|
||||||
|
|||||||
@@ -16,6 +16,8 @@
|
|||||||
package realtime
|
package realtime
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -34,6 +36,8 @@ import (
|
|||||||
// ?alertId=<uuid> required
|
// ?alertId=<uuid> required
|
||||||
// &token=<secret> required; the alert's admin token OR its subscribe token
|
// &token=<secret> required; the alert's admin token OR its subscribe token
|
||||||
// &deviceId=<id> optional; picks which device row is kept alive
|
// &deviceId=<id> optional; picks which device row is kept alive
|
||||||
|
// &deviceToken=<t> the subscriber credential (rev 10); replaces the access
|
||||||
|
// token for a device that has no account
|
||||||
//
|
//
|
||||||
// The caller's identity always comes from a verified X-Access-Token header. The
|
// The caller's identity always comes from a verified X-Access-Token header. The
|
||||||
// access token may also be supplied as ?accessToken= / ?access_token= for
|
// access token may also be supplied as ?accessToken= / ?access_token= for
|
||||||
@@ -44,6 +48,16 @@ import (
|
|||||||
// the alert, so knowing the shared token is not enough on its own. The admin
|
// the alert, so knowing the shared token is not enough on its own. The admin
|
||||||
// token is only honoured for the user the alert was created by.
|
// token is only honoured for the user the alert was created by.
|
||||||
func authenticateWS(r *http.Request) (*Identity, error) {
|
func authenticateWS(r *http.Request) (*Identity, error) {
|
||||||
|
// Device path first (rev 10): no access token, no account — the device token
|
||||||
|
// names a subscription and `token` must still prove the caller was shown the
|
||||||
|
// QR.
|
||||||
|
if deviceTok := strings.TrimSpace(r.URL.Query().Get("deviceToken")); deviceTok != "" || strings.TrimSpace(r.Header.Get("X-Device-Token")) != "" {
|
||||||
|
if deviceTok == "" {
|
||||||
|
deviceTok = strings.TrimSpace(r.Header.Get("X-Device-Token"))
|
||||||
|
}
|
||||||
|
return authenticateWSDevice(r, deviceTok)
|
||||||
|
}
|
||||||
|
|
||||||
access := strings.TrimSpace(r.Header.Get("X-Access-Token"))
|
access := strings.TrimSpace(r.Header.Get("X-Access-Token"))
|
||||||
if access == "" {
|
if access == "" {
|
||||||
access = strings.TrimSpace(r.URL.Query().Get("accessToken"))
|
access = strings.TrimSpace(r.URL.Query().Get("accessToken"))
|
||||||
@@ -148,3 +162,70 @@ func resolveSubscription(r *http.Request, alertID, userID uuid.UUID) (*db.Subscr
|
|||||||
}
|
}
|
||||||
return db.GetSubscription(r.Context(), alertID, ids[0])
|
return db.GetSubscription(r.Context(), alertID, ids[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// authenticateWSDevice upgrades a subscriber connection that has no account.
|
||||||
|
// The device token is looked up by hash; the shared subscribe token still has to
|
||||||
|
// match, so a leaked device token alone cannot attach to an alert's socket.
|
||||||
|
func authenticateWSDevice(r *http.Request, deviceToken string) (*Identity, error) {
|
||||||
|
alertID, err := uuid.Parse(strings.TrimSpace(r.URL.Query().Get("alertId")))
|
||||||
|
if err != nil {
|
||||||
|
return nil, &AuthError{
|
||||||
|
Status: http.StatusBadRequest, Code: apierr.Validation, Message: "alertId is required",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sub, err := db.GetSubscriptionByToken(r.Context(), deviceTokenHash(deviceToken))
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, db.ErrNotFound) {
|
||||||
|
return nil, &AuthError{
|
||||||
|
Status: http.StatusUnauthorized, Code: apierr.Unauthorized, Message: "invalid device token",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.Error("ws: device token lookup failed: %v", err)
|
||||||
|
return nil, &AuthError{
|
||||||
|
Status: http.StatusInternalServerError, Code: apierr.Internal, Message: "internal error",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if sub.AlertID != alertID {
|
||||||
|
return nil, &AuthError{
|
||||||
|
Status: http.StatusNotFound, Code: apierr.AlertNotSubscribed, Message: "device is not subscribed to this alert",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
token := strings.TrimSpace(r.URL.Query().Get("token"))
|
||||||
|
if token == "" {
|
||||||
|
return nil, &AuthError{
|
||||||
|
Status: http.StatusForbidden, Code: apierr.AlertTokenInvalid, Message: "token is required",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
alert, err := db.GetAlert(r.Context(), alertID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, db.ErrNotFound) {
|
||||||
|
return nil, &AuthError{
|
||||||
|
Status: http.StatusNotFound, Code: apierr.AlertNotFound, Message: "alert not found",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.Error("ws: alert lookup failed: %v", err)
|
||||||
|
return nil, &AuthError{
|
||||||
|
Status: http.StatusInternalServerError, Code: apierr.Internal, Message: "internal error",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !alarmtoken.EqualToken(alert.SubscribeToken, token) {
|
||||||
|
logger.Warn("ws: subscribe token mismatch alert=%s device-sub=%s", alert.ID, sub.ID)
|
||||||
|
return nil, &AuthError{
|
||||||
|
Status: http.StatusForbidden, Code: apierr.AlertTokenInvalid, Message: "invalid token",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &Identity{
|
||||||
|
AlertID: alert.ID,
|
||||||
|
SubscriptionID: &sub.ID,
|
||||||
|
DeviceID: sub.DeviceID,
|
||||||
|
IsDevice: true,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// deviceTokenHash mirrors the service's device-token hash (plain SHA-256 hex):
|
||||||
|
// the WebSocket package verifies the same credential the actions accept, so the
|
||||||
|
// transform must be identical.
|
||||||
|
func deviceTokenHash(tok string) string {
|
||||||
|
sum := sha256.Sum256([]byte(tok))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|||||||
@@ -149,7 +149,13 @@ func (c *client) ackDelivery(eventID string) {
|
|||||||
// nothing to record and no drain to check.
|
// nothing to record and no drain to check.
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := db.RecordDelivered(ctx, c.identity.AlertID, c.identity.UserID); err != nil {
|
// Anonymous subscribers have no account to record analytics against: the
|
||||||
|
// subscription row is the only identity, so keep it alive instead.
|
||||||
|
if c.identity.SubscriptionID != nil {
|
||||||
|
if err := db.TouchSubscription(ctx, *c.identity.SubscriptionID); err != nil {
|
||||||
|
c.hub.opts.Logf("ws: touch subscription alert=%s: %v", c.identity.AlertID, err)
|
||||||
|
}
|
||||||
|
} else if err := db.RecordDelivered(ctx, c.identity.AlertID, c.identity.UserID); err != nil {
|
||||||
c.hub.opts.Logf("ws: delivered analytics alert=%s: %v", c.identity.AlertID, err)
|
c.hub.opts.Logf("ws: delivered analytics alert=%s: %v", c.identity.AlertID, err)
|
||||||
}
|
}
|
||||||
if _, err := db.CompleteAlertDeliveries(ctx, c.identity.AlertID); err != nil {
|
if _, err := db.CompleteAlertDeliveries(ctx, c.identity.AlertID); err != nil {
|
||||||
|
|||||||
+4
-1
@@ -91,9 +91,12 @@ type ClientFrame struct {
|
|||||||
|
|
||||||
// Identity is the authenticated WebSocket caller.
|
// Identity is the authenticated WebSocket caller.
|
||||||
type Identity struct {
|
type Identity struct {
|
||||||
AlertID uuid.UUID
|
AlertID uuid.UUID
|
||||||
|
// UserID is uuid.Nil for an anonymous subscriber: a device token is not a
|
||||||
|
// user (contract rev 10).
|
||||||
UserID uuid.UUID
|
UserID uuid.UUID
|
||||||
IsAdmin bool
|
IsAdmin bool
|
||||||
|
IsDevice bool
|
||||||
SubscriptionID *uuid.UUID
|
SubscriptionID *uuid.UUID
|
||||||
DeviceID string
|
DeviceID string
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user