Commit Graph
4 Commits
Author SHA1 Message Date
Admin ca2130258b [fix](alarm): make dismissal stick and count the devices
Four defects, three of them the same missing piece of the delivery ledger.

- listSubscriptions scanned nine destinations for ten columns and read a NULL
  subscriber_user_id into a uuid. Every alert with an anonymous subscriber
  answered 500 ("number of field descriptions must equal number of
  destinations, got 10 and 9"), and the client renders a failed read as an
  empty list — so 'Subscribed devices' said 0 while the row existed. The
  listing now uses the same scan as every other subscription read.
- alarmStatus reported the alert's commanded state, not the caller's own. An
  alert stays 'triggered' until the owner disarms or every subscriber acks, so
  a device that had already dismissed the alarm was told active:true on every
  poll and re-raised its takeover. active is now true only while the caller
  still owes the newest trigger; the owner keeps seeing their own alert.
- ackAlarm required an eventId, which the native takeover launch does not
  have. A dismissal that cannot name its event now clears the subscription's
  whole outstanding set (AckAllDeliveries / AckAllForSubscriber), so the debt
  cannot survive because the client could not echo a uuid.
- The device ledger probe (gated on DATABASE_URL, like the lifecycle probe)
  pins the per-subscriber debt, the ack-all semantics and the cascade that
  takes a device token with the destroyed alert.

Verified against the dev database and through Maestro: 2 subscriptions listed,
device A acked -> its status turns active:false while device B still gets the
409 check-in, ack-all without eventId clears the debt and drains the alert.
2026-10-19 19:34:24 +01:00
Admin 0e24c4e4f0 [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.
2026-10-19 18:37:17 +01:00
Admin 0334d7fe93 [feat](alarm): store device ids as sha256 digests
device_id is a stable pseudonymous idempotency key, not a credential; a database dump should not be a usable list of device identifiers. Hash every caller-supplied value before it reaches the table, keep raw values out of logs and subscription views, and migrate existing rows with the identical SQL encoding.
2026-10-19 02:26:31 +01:00
Admin ec38e680d6 init 2026-10-18 22:31:16 +01:00