[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
+63 -7
View File
@@ -25,6 +25,12 @@ import (
)
// 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 {
ID uuid.UUID
AlertID uuid.UUID
@@ -35,15 +41,22 @@ type Subscription struct {
Enabled bool
LastSeenAt *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,
platform, enabled, last_seen_at, created`
platform, enabled, last_seen_at, created, device_token_hash`
func scanSubscription(row pgx.Row) (*Subscription, error) {
var s Subscription
err := row.Scan(&s.ID, &s.AlertID, &s.SubscriberUserID, &s.DeviceID, &s.DeviceLabel,
&s.Platform, &s.Enabled, &s.LastSeenAt, &s.Created)
var user *uuid.UUID
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) {
return nil, ErrNotFound
}
@@ -65,19 +78,24 @@ func CreateSubscription(ctx context.Context, s *Subscription) (*Subscription, er
}
defer func() { _ = tx.Rollback(ctx) }()
var actor *uuid.UUID
if s.SubscriberUserID != uuid.Nil {
actor = &s.SubscriberUserID
}
const q = `INSERT INTO alarm.subscriptions
(alert_id, subscriber_user_id, device_id, device_label, platform)
VALUES ($1, $2, $3, $4, $5)
(alert_id, subscriber_user_id, device_id, device_label, platform, device_token_hash)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING ` + subscriptionColumns
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) {
return nil, ErrConflict
}
if err != nil {
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
}
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))
}
// 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.
func GetSubscription(ctx context.Context, alertID, subscriptionID uuid.UUID) (*Subscription, error) {
const q = `SELECT ` + subscriptionColumns + `