[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:
2026-10-19 18:37:17 +01:00
parent 914fd6570d
commit 0e24c4e4f0
15 changed files with 777 additions and 98 deletions
+39 -17
View File
@@ -187,7 +187,7 @@ type statusView struct {
// background job is needed to expire anything.
func AlarmStatus(w http.ResponseWriter, r *http.Request, payload any) {
const action = "alarmStatus"
p, ok := principal(w, r, action)
c, ok := callerFor(w, r, action)
if !ok {
return
}
@@ -202,14 +202,21 @@ func AlarmStatus(w http.ResponseWriter, r *http.Request, payload any) {
dbError(w, action, err)
return
}
allowed, err := isAdminOrSubscriber(r, res.Alert, p.ID)
if err != nil {
dbError(w, action, err)
return
}
if !allowed {
writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "not subscribed to this alert")
return
if c.IsDevice {
if !c.subscribedTo(alertID) {
writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "not subscribed to this alert")
return
}
} else {
allowed, err := isAdminOrSubscriber(r, res.Alert, c.UserID)
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
@@ -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
// 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).
deviceHash := ""
if raw := strings.TrimSpace(r.URL.Query().Get("deviceId")); raw != "" {
deviceHash = deviceid.Hash(raw)
}
pending, err := db.PendingTrigger(r.Context(), alertID, p.ID, deviceHash)
if err != nil && !errors.Is(err, db.ErrNotFound) {
dbError(w, action, err)
return
var pending *db.PendingDelivery
if c.IsDevice {
// The device token already identifies the subscription, so its whole
// pending set is the debt to report.
rows, err := db.PendingReplay(r.Context(), alertID, c.Sub.ID)
if err != nil && !errors.Is(err, db.ErrNotFound) {
dbError(w, action, err)
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 {
writeOfflineAlarm(w, res.Alert, pending)