[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
+75
View File
@@ -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 $$;