Compare commits

...
10 Commits
Author SHA1 Message Date
Admin 9f1ac9c25b Separate delivery receipts from dismissals
The client acks a frame as soon as it arrives — that is how an online device
stops being re-told — and the service recorded that ack in acked_at, the
column the drain-to-empty rule counts. Measured on an Android 15 emulator:
the owner's trigger reached the subscriber at .562 and the alert was
destroyed 27 ms later, mid-siren, so the owner's disarm answered
ERR_ALERT_NOT_FOUND.

alarm_deliveries now carries two timestamps. delivered_at is the receipt
(the WS ack frame and the offline check-in): it retires re-delivery and
nothing else. acked_at is the human dismissal (ackAlarm): only that ends the
alert, once every subscription's row is dismissed. Migration 011 adds the
column and replaces the pending index with an undelivered one.

ackAlarm also runs the drain check on every explicit dismissal, not only when
this call cleared a row: a receipted row still has to be dismissed.

Also fixed, found by the same probe run: db.Close left the closed pool as the
process singleton (a second probe inherited it), and the lifecycle probe's
three subscriptions shared one device id, which migration 010 made illegal.

Verified: both probes pass against the live dev database, including the new
receipt-vs-dismissal case; migration 011 applied to the dev DB.
2026-10-19 20:40:23 +01:00
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 914fd6570d [fix](alarm): label the debug compose for the data plane
Maestro resolves a service through the org.dcntrlized.service and org.dcntrlized.port labels; without them the single data plane answers unknown service for the dev stack, which is the only supported client path. Allow AUTH_SERVICE_URL to be overridden so the dev fleet can point at auth-debug.
2026-10-19 02:52:02 +01:00
Admin 3cd04105bb [docs](alarm): record the four alert endings
Document the wire contract as implemented: single-origin access through Maestro, no auto-stop, the owner-set persistence cap, the durable delivery ledger and its 409 check-in, explicit acks, hashed device ids, one anonymised event log, and the four ways an alert ends (delivered, expired, disarmed, deleted). Also corrects the heading to match the table it introduces.
2026-10-19 02:28:59 +01:00
Admin 6873b1a3f1 [chore](alarm): ignore test logs from every source package
Tests run with their package directory as cwd, so any package can drop a logs/ directory. The per-package list kept going stale; match src/**/logs instead.
2026-10-19 02:28:59 +01:00
Admin 55e83d6bfd [feat](alarm): hash client identifiers in stdout request logs
A request log should not name a client directly, and the durable event log deliberately has no network columns. Hash the IP and user agent with internal/loghash for the stdout line; empty values stay empty rather than logging a constant digest. This is record anonymisation, not cryptographic protection, and the package says so.
2026-10-19 02:28:59 +01:00
Admin 028f9e7dae [refactor](alarm): make a readable message structural on every error
An error status with an empty body is a client-breaking bug that review does not reliably catch. Route every JSON and plain-text error through internal/apierr, which substitutes the code canonical message when a caller supplies none, and keep the frozen codes in one package shared by the action and WebSocket layers.
2026-10-19 02:26:46 +01:00
Admin 3ccea3e9f6 [feat](alarm): keep one anonymised lifecycle event log
Record created/joined/triggered/delivered/destroyed with SHA-256 hashes of the alert and user UUIDs only, and a reason on destruction. The earlier analytics and stdout-mirror tables were superseded by the user ruling, so 009 creates alarm.event_log, migrates what still applies and drops both, leaving exactly one durable table with no network identifiers, token, body or query string. Retention is age-based via the sweeper.
2026-10-19 02:26:46 +01:00
Admin 6e6a1e9c8a [feat](alarm): add a durable per-device delivery ledger
pg_notify and the socket fan-out are not durable, so an offline phone used to miss a trigger forever and no-auto-stop would leave it ringing at nobody. Write one ledger row per enabled subscription in the trigger transaction, replay what a subscription still owes on every socket connect, and clear a row only on an explicit ack (live socket ack or ackAlarm); alarmStatus is a read-only check-in that answers 409 ERR_ALARM_TRIGGERED_OFFLINE with the missed frame. Ack is idempotent, and a destroyed alert cascades its debt away so a client is never told it missed an alarm that no longer exists.
2026-10-19 02:26:46 +01:00
41 changed files with 3621 additions and 284 deletions
+3 -6
View File
@@ -3,9 +3,6 @@ src/tmp/
src/logs/ src/logs/
# Logger writes its logs/ dir relative to the working directory, so package # Logger writes its logs/ dir relative to the working directory, so package
# tests (which run with the package dir as cwd) can drop logs under src/*/. # tests (which run with the package dir as cwd) drop logs under any src/*/.
src/handlers/config/logs/ # Glob every package so a new test package cannot leak a stray log file.
src/handlers/manifest/logs/ src/**/logs/
src/actions/logs/
src/realtime/logs/
src/db/logs/
+457 -84
View File
@@ -15,6 +15,47 @@ Everything — the API, the auth proxy and the WebSocket — rides that **single
port**. There is no second listener and no TLS on the service itself (an edge port**. There is no second listener and no TLS on the service itself (an edge
proxy terminates TLS). proxy terminates TLS).
### How a client reaches this service
An instance exposes **one** port. Apps and phones do not address a service
directly — not this one, not any other: they address the instance, and Maestro's
data plane relays the request. There is no per-service hostname, and direct
service access is not a supported deployment.
So a client's base URL is `http(s)://<instance>`, and exactly three namespaces
are reachable (`Maestro/src/handlers/dataplane/`, one allowlist, one dispatcher,
HTTP or WebSocket by upgrade presence):
| Public URL | Service path | Purpose |
| --- | --- | --- |
| `{base}/api/alarm/action/{actionName}` | `/action/{actionName}` | the action API documented below |
| `{base}/api/alarm/auth/{path...}` | `/auth/{path...}` | shared-auth proxy (`login`, `register`, `refresh`, `me`, …) |
| `{base}/api/alarm/ws/alarm` | `/ws/alarm` | the live alarm socket (WebSocket upgrade) |
- The data plane is **not** behind Maestro's owner gate: `Maestro/src/main.go`
registers it deliberately outside `RequireSession`, because its callers are
apps and phones that hold no Maestro session and must never be handed the
instance operator's cookie. **The service is the gate** — Alarm validates the
access token and the alert token itself and answers `401`/`403` on its own.
History for the next reader: the action surface shipped ungated, was wrapped
in the owner gate by commit `2ffdf77`, and that gate was a regression for
phone traffic — not a design.
- The query string is forwarded **verbatim** (tokens live there) and
`X-Access-Token` is forwarded untouched. `Authorization` and `Cookie` are
**not** forwarded, so Alarm never sees them; `Sec-WebSocket-*` is rebuilt by
the relay.
- The service's answer is the answer: status, body, `Content-Type` and
end-to-end headers are mirrored byte-for-byte, redirects included. Maestro
envelopes **only its own** failures — `404 ERR_NOT_FOUND` (unknown
service/namespace), `409 ERR_NOT_RUNNING` (stopped container),
`502 ERR_UPSTREAM_UNREACHABLE`, `500 ERR_INTERNAL` (its own plumbing) — with
`{"error":{"code","message"}}`. Hop-by-hop headers and `Set-Cookie` are
stripped by the relay; Alarm sets no cookies, so that changes nothing here.
- `/config` and `/_manifest` are deliberately **unreachable** through the data
plane: `/config` is Maestro's own ES256-signed edge and exposing it would let
a caller skip the mint; `/_manifest` is consumed by Maestro, not by clients.
The manifest is documented below for completeness.
## Routes ## Routes
| Route | Purpose | | Route | Purpose |
@@ -67,8 +108,9 @@ Two further credentials exist, both **in the payload** (never in a header):
- `adminToken` — the alert's admin secret. Minted once by `createAlert`, - `adminToken` — the alert's admin secret. Minted once by `createAlert`,
stored only as a SHA-256 hash server-side, and required by the destructive stored only as a SHA-256 hash server-side, and required by the destructive
actions. actions.
- `subscribeToken` — the shareable secret rendered as the QR code. It is - `subscribeToken` — the shareable secret rendered as the QR code. It is minted
*rotatable* and is the only thing a joining client needs. once per alert and never rotates (subscribers are permanent for the alert's
lifetime); it is the only thing a joining client needs.
Errors are **not uniform across the request pipeline**. Clients must know Errors are **not uniform across the request pipeline**. Clients must know
where the boundary is. where the boundary is.
@@ -117,6 +159,7 @@ Consequences worth internalising:
| `ERR_ALERT_NOT_SUBSCRIBED` | 404 | caller is neither the admin nor a subscriber of this alert | | `ERR_ALERT_NOT_SUBSCRIBED` | 404 | caller is neither the admin nor a subscriber of this alert |
| `ERR_ALERT_ALREADY_JOINED` | 409 | unique-constraint race on `(alertId, user, deviceId)`; the normal re-scan path is idempotent and returns `200` | | `ERR_ALERT_ALREADY_JOINED` | 409 | unique-constraint race on `(alertId, user, deviceId)`; the normal re-scan path is idempotent and returns `200` |
| `ERR_ALERT_STATE` | 409 | the action is illegal in the alert's current state (e.g. disarming a non-triggered alert, deleting a live one) | | `ERR_ALERT_STATE` | 409 | the action is illegal in the alert's current state (e.g. disarming a non-triggered alert, deleting a live one) |
| `ERR_ALARM_TRIGGERED_OFFLINE` | 409 | **not an error**: `alarmStatus` is telling this device it missed a trigger while offline and must ring now (the body carries the frame) |
| `ERR_INTERNAL` | 500 | unexpected server/database failure | | `ERR_INTERNAL` | 500 | unexpected server/database failure |
`ERR_METHOD_NOT_ALLOWED` (405) and `ERR_UNAVAILABLE` (503) also exist on the `ERR_METHOD_NOT_ALLOWED` (405) and `ERR_UNAVAILABLE` (503) also exist on the
@@ -124,6 +167,14 @@ WebSocket endpoint — see below. These codes only ever appear in a JSON
envelope; the dispatch-level `405` described above is plain text and carries no envelope; the dispatch-level `405` described above is plain text and carries no
code. code.
**Every error response carries a human-readable message; there is no
bare-status form on the wire.** A JSON error always has a non-empty `code`
**and** `message` (the emitter substitutes the code's canonical message if a
handler ever supplies an empty one, so this holds by construction rather than by
convention), and a plain-text dispatch error always has a non-empty body. No
documented case exists where an error message is legitimately empty — if one is
ever needed, it must be added here first.
## Action summary ## Action summary
The dispatcher routes by HTTP method (`GET`, `POST`, `PUT`, `DELETE`); in every The dispatcher routes by HTTP method (`GET`, `POST`, `PUT`, `DELETE`); in every
@@ -145,10 +196,10 @@ GET-only** (`POST /action/me` is a `501`). `DELETE` accepts the two removals.
| `removeSubscription` | DELETE, POST | — | admin (any row) or the subscriber (own row) | | `removeSubscription` | DELETE, POST | — | admin (any row) or the subscriber (own row) |
| `triggerAlert` | POST | required | admin | | `triggerAlert` | POST | required | admin |
| `disarmAlert` | POST | required | admin | | `disarmAlert` | POST | required | admin |
| `alarmStatus` | GET, POST | — | admin **or** subscriber | | `ackAlarm` | POST | — | admin **or** subscriber; subscribers must send `deviceId` |
| `alarmStatus` | GET, POST | — | admin **or** subscriber; read-only (reports debt, clears nothing) |
| `deleteAlert` | DELETE, POST | required | admin | | `deleteAlert` | DELETE, POST | required | admin |
| `qrImage` | GET, POST | — | admin **or** subscriber | | `qrImage` | GET, POST | — | admin **or** subscriber |
| `rotateSubscribeToken` | POST | required | admin |
### Shared-auth actions (`/auth/*` proxies) ### Shared-auth actions (`/auth/*` proxies)
@@ -162,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": "…" }` |
@@ -171,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`
@@ -182,8 +263,7 @@ Creates an alert and makes the caller its admin.
```json ```json
{ "payload": { { "payload": {
"name": "Fire drill", "name": "Fire drill",
"message": "Leave the building now", "message": "Leave the building now"
"autoStopSeconds": 300
} } } }
``` ```
@@ -191,7 +271,6 @@ Creates an alert and makes the caller its admin.
| --- | --- | --- | | --- | --- | --- |
| `name` | string | required, 1–255 characters after trimming | | `name` | string | required, 1–255 characters after trimming |
| `message` | string | optional, ≤ 2000 bytes, the default text pushed with a trigger | | `message` | string | optional, ≤ 2000 bytes, the default text pushed with a trigger |
| `autoStopSeconds` | int | optional, `0`–`86400`; `0` (default) means "ring until disarmed" |
Response `200`: Response `200`:
@@ -205,8 +284,6 @@ Response `200`:
"adminToken": "…", // shown ONCE, store it "adminToken": "…", // shown ONCE, store it
"subscribeToken": "…", "subscribeToken": "…",
"qrPayload": "https://alarm.dcntrlized.org/a/0f8f…e#…", "qrPayload": "https://alarm.dcntrlized.org/a/0f8f…e#…",
"autoStopSeconds": 300,
"tokenVersion": 1,
"created": "2026-10-18T17:00:00Z" "created": "2026-10-18T17:00:00Z"
} }
``` ```
@@ -214,6 +291,19 @@ Response `200`:
`adminToken` is returned **only here** (and nowhere else, ever). The server `adminToken` is returned **only here** (and nowhere else, ever). The server
keeps a hash. Losing it means losing admin rights over that alert. keeps a hash. Losing it means losing admin rights over that alert.
There is **no auto-stop and no ringing duration**: an alert created here rings
from the moment it is triggered until its owner disarms it (see `triggerAlert`).
An old client that still sends `autoStopSeconds` is accepted — unknown payload
fields are ignored — but the value has no effect.
There *is* an owner-set **persistence** window, and it is a different clock:
[`expiresInSeconds`](#validation-and-the-persistence-cap) sets how long the alert
itself lives (default 24 h, minimum 60 s, hard maximum 14 days = 1 209 600 s).
Above the cap is `ERR_VALIDATION`, not a silent clamp. The response carries
`expiresAt` (RFC 3339), as does every `alertView`. After that instant the alert
is destroyed with everything about it, and clients see `ERR_ALERT_NOT_FOUND` —
stragglers are ignored by design.
Errors: `ERR_VALIDATION`, `ERR_UNAUTHORIZED`. Errors: `ERR_VALIDATION`, `ERR_UNAUTHORIZED`.
## `getAlert` ## `getAlert`
@@ -231,8 +321,6 @@ Response `200`:
"message": "Leave the building now", "message": "Leave the building now",
"status": "triggered", // "armed" | "triggered" | "disarmed" "status": "triggered", // "armed" | "triggered" | "disarmed"
"active": true, // derived, see alarmStatus "active": true, // derived, see alarmStatus
"autoStopSeconds": 300,
"tokenVersion": 1,
"created": "2026-10-18T17:00:00Z", "created": "2026-10-18T17:00:00Z",
"modified": "2026-10-18T17:05:00Z", "modified": "2026-10-18T17:05:00Z",
"triggeredAt": "2026-10-18T17:05:00Z", "triggeredAt": "2026-10-18T17:05:00Z",
@@ -262,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": {
@@ -284,11 +376,17 @@ Subscribes the caller's device. This is what the QR scan calls.
`deviceId` is part of the subscription's identity, so it is **not optional**: `deviceId` is part of the subscription's identity, so it is **not optional**:
missing or empty is `400 ERR_VALIDATION` (`deviceId is required`). Generate it missing or empty is `400 ERR_VALIDATION` (`deviceId is required`). Generate it
once per install (a random UUID persisted in app storage is enough) and never once per install (a random value persisted in app storage is enough) and never
rotate it — a new value makes the same user a separate subscription, which is change it — a new value makes the same user a separate subscription, which is
exactly the "second device" behaviour. Reuse the same value as `?deviceId=` on exactly the "second device" behaviour. Reuse the same value as `?deviceId=` on
`/ws/alarm` so the right subscription's `last_seen_at` is refreshed. `/ws/alarm` so the right subscription's `last_seen_at` is refreshed.
**The server never stores the raw value: it stores `sha256:<64 lowercase hex>`**
(`crypto/sha256`, 71 bytes, inside `VARCHAR(128)`). Every comparison hashes the
caller's input first — this join, the unique key, and the `?deviceId=` lookup on
the socket — so behaviour is unchanged and the client neither learns nor needs
the hash. It keeps sending the raw value.
Response `200` — the subscription view (see `listSubscriptions`). Response `200` — the subscription view (see `listSubscriptions`).
**Idempotent per `(alertId, user, deviceId)`**: re-scanning the same QR on the **Idempotent per `(alertId, user, deviceId)`**: re-scanning the same QR on the
@@ -310,7 +408,7 @@ is *not* required for this read).
```json ```json
{ "items": [ { "items": [
{ "id": "…", "userId": "…", "deviceId": "android-8f2c…", { "id": "…", "userId": "…",
"deviceLabel": "Pixel 7", "platform": "android", "deviceLabel": "Pixel 7", "platform": "android",
"enabled": true, "lastSeenAt": "2026-10-18T17:04:00Z", "enabled": true, "lastSeenAt": "2026-10-18T17:04:00Z",
"created": "2026-10-18T16:00:00Z" } "created": "2026-10-18T16:00:00Z" }
@@ -322,6 +420,19 @@ schema and Alarm does not read another service's tables. The field is omitted
rather than sent empty. `lastSeenAt` is refreshed whenever that subscription's rather than sent empty. `lastSeenAt` is refreshed whenever that subscription's
socket connects or heartbeats. socket connects or heartbeats.
`deviceId` is intentionally **not** in this view. Note who "admin" means here:
the **alert owner** — the user who created this particular alert and holds its
admin token. It is *not* the Maestro instance operator; no instance-level
operator ever reads this view. The value is a stable pseudonymous idempotency
key (see `joinAlert`), not a credential, and the alert owner has no need to read
every subscriber's device identifier. The subscription `id` is the revocation
handle (`removeSubscription` takes it). To tell several identical-looking
devices apart — labels are roles now, so three Android phones share one label —
use `created` (the join time), `lastSeenAt`, `deviceLabel` and `platform`; none
of those identifies a device. The digest is still stored server-side and is
still part of the `(alertId, subscriber, device digest)` unique key that makes a
re-join idempotent; only the read exposure is removed.
Errors: `ERR_VALIDATION`, `ERR_ALERT_NOT_FOUND`, `ERR_ALERT_NOT_ADMIN`, Errors: `ERR_VALIDATION`, `ERR_ALERT_NOT_FOUND`, `ERR_ALERT_NOT_ADMIN`,
`ERR_UNAUTHORIZED`. `ERR_UNAUTHORIZED`.
@@ -367,7 +478,6 @@ Response `200`:
"eventId": "…", "eventId": "…",
"action": "trigger", "action": "trigger",
"serverTime": "2026-10-18T17:05:00.123Z", "serverTime": "2026-10-18T17:05:00.123Z",
"autoStopSeconds": 300,
"delivered": 2 "delivered": 2
} }
``` ```
@@ -377,8 +487,45 @@ Response `200`:
not connected (or are on another instance) are reached by the reconnect not connected (or are on another instance) are reached by the reconnect
catch-up and by the `alarmStatus` fallback instead. catch-up and by the `alarmStatus` fallback instead.
- Re-triggering an already-triggered alert is allowed and always produces a - Re-triggering an already-triggered alert is allowed and always produces a
**new `eventId`**, restarting the auto-stop window. Clients must treat each **new `eventId`**. Clients must treat each `eventId` as a new alarm.
`eventId` as a new alarm.
### Two clocks, four endings
Keep these apart; conflating them is how the next reader gets it wrong.
**Clock 1 — ringing.** There is no auto-stop and no ringing timer. An alert is
active from the moment it is triggered until it ends. A subscriber's *ringing*
ends when that subscriber dismisses it locally; that is a client action which by
itself changes no server state until the client sends `ackAlarm` (or the socket
`ack`), and the server never depends on a client's cooperation to stop an event.
**Clock 2 — alert lifetime.** `expires_at`, chosen by the owner at creation
(cap 14 days), bounds how long the alert exists at all. It is not a ringing
timer: it is the persistence cap that stops a device that never returns from
pinning an alert forever.
An alert ends in exactly three ways, each destroyed through the same path with
its own `event_log` row (`event:"destroyed"`, `reason`):
| Ending | Trigger | Analytics reason | Note |
|---|---|---|---|
| **delivered** | every enabled subscription has acked (`ackAlarm`/WS `ack`) | `delivered` | drain-to-empty; a trigger with **zero** subscribers never counts as drained |
| **expired** | `now() >= expires_at` | `expired` | destroys even with unacked deliveries — stragglers are ignored |
| **disarmed** | owner calls `disarmAlert` (triggered **or** still armed) | `disarmed` | on an armed alert this is "delete it without ever firing" |
| **deleted** | owner calls `deleteAlert` explicitly | `deleted` | converges with disarm for an armed alert; kept as a separate intent |
`disarmAlert` therefore ends an armed alert too — it is not a no-op and not a
re-arm. Deleting or disarming an alert removes its delivery-ledger rows by
cascade, so a device that still had an unacked delivery is **never** later told
"you missed a trigger": replay and the 409 read only that ledger, and a destroyed
alert has no debt left to report. On reconnect the client learns the alert is
gone (`ERR_ALERT_NOT_FOUND`) — that means **ended**, not **triggered**.
Deliberate consequence, so it is not a surprise later: an owner who loses their
phone (or admin token) cannot stop a ringing alert before it expires — but the
persistence cap now guarantees the alert (and its debt) eventually disappears.
That trade — an alarm nobody can miss versus one nobody can stop forever — is
the accepted design.
- The alert state is stored **before** the fan-out, so a client that reconnects - The alert state is stored **before** the fan-out, so a client that reconnects
immediately after the call still sees the live event. immediately after the call still sees the live event.
@@ -401,29 +548,33 @@ Response `200` — same shape as `triggerAlert` with `"action": "disarm"`:
"eventId": "…", "eventId": "…",
"action": "disarm", "action": "disarm",
"serverTime": "2026-10-18T17:09:00.456Z", "serverTime": "2026-10-18T17:09:00.456Z",
"autoStopSeconds": 300,
"delivered": 2 "delivered": 2
} }
``` ```
`autoStopSeconds` echoes the alert's **configured** value (e.g. `300`), not Disarm **ends the alert**, whatever its state:
`0`: disarming does not rewrite the stored column, and the same value rides the
broadcast `disarm` frame. Treat it as informational — "that is how long the
trigger would have rung" — never as a stop signal. **Cancel the ring on
`action: "disarm"`** (or on `active: false` from `alarmStatus`). A client that
waits for `autoStopSeconds === 0` before stopping will keep ringing.
Disarming an alert that is **not** currently active is `409 ERR_ALERT_STATE`. - **triggered** — the live alarm ends; outstanding deliveries cascade away
(closed, not acked) and the alert is destroyed with event-log reason
`disarmed`;
- **armed / never triggered** — the alert is destroyed as well. This is the
user's "delete the whole alarm without triggering it": clients learn it ended,
never that it fired.
Errors: as `triggerAlert`, plus `ERR_ALERT_STATE`. **Cancel the ring on `action: "disarm"`** (or on `active:false`). Disarming an
already-destroyed alert is `ERR_ALERT_NOT_FOUND`; the action is idempotent in
intent and cannot destroy twice or log twice.
Errors: as `triggerAlert`, plus `ERR_ALERT_NOT_FOUND`.
## `alarmStatus` ## `alarmStatus`
The cheap, indexed fallback a client polls when its socket is down, or on cold The cheap, indexed fallback a client polls when its socket is down, or on cold
start when it may have been killed mid-alarm. start when it may have been killed mid-alarm.
**GET** `/action/alarmStatus?alertId=<uuid>` · **GET** `/action/alarmStatus?alertId=<uuid>[&deviceId=<raw id>]` ·
**POST** `{ "payload": { "alertId": "…" } }` **POST** `{ "payload": { "alertId": "…" } }` (a `deviceId` in the payload is not
read; send it in the query if you want to pin the subscription)
```json ```json
{ {
@@ -431,21 +582,137 @@ start when it may have been killed mid-alarm.
"eventId": "…", "eventId": "…",
"action": "trigger", "action": "trigger",
"message": "Leave the building now", "message": "Leave the building now",
"serverTime": "2026-10-18T17:05:00.123Z", "serverTime": "2026-10-18T17:05:00.123Z"
"autoStopSeconds": 300
} }
``` ```
`active` is **derived**, never a timer: it is true only when the newest event is `active` is **derived**, never a ringing timer: it is true only when the newest
a `trigger`, the stored status is `triggered`, and (when `autoStopSeconds > 0`) event is a `trigger`, the stored status is `triggered`, **and this caller still
`now < triggeredAt + autoStopSeconds`. Nothing sweeps the database, so the owes the alarm** (an unacknowledged delivery row for its subscription). The
answer stays correct across restarts and across instances. When there is no answer stays correct across restarts and instances, and it stays `true` until
event yet the fields are empty and `active` is `false`. the owner disarms, the alert expires, or this caller acknowledges.
The caller-scoped `active` is what makes dismissal final: an alert stays
commanded `triggered` until the owner disarms it or *every* subscriber ack, so a
device that already dismissed the alarm would otherwise be told `active: true`
on every poll and re-raise its takeover. A client must treat `active: false`
with a stale `eventId` as "nothing owed by this device", not as a new trigger.
The alert's owner (who has no delivery row) keeps seeing their own alert's
commanded state. (There *is* a sweeper, but it destroys expired
alerts — it never changes an event's meaning.) An expired or destroyed alert is
`ERR_ALERT_NOT_FOUND` here like everywhere else. When there is no event yet the
fields are empty and `active` is `false`.
`serverTime` is the event's timestamp (or the server's clock when there is no `serverTime` is the event's timestamp (or the server's clock when there is no
event) — use it to correct client clock skew rather than comparing with the event) — use it to correct client clock skew rather than comparing with the
device clock. device clock.
### Offline check-in — `409 ERR_ALARM_TRIGGERED_OFFLINE`
The same call is the offline check-in. A trigger writes a **durable delivery
ledger row** for every enabled subscription
(`alarm.alarm_deliveries`), so a device that was offline at trigger time has a
record owing it an alarm even though `pg_notify` and the in-memory catch-up
never reached it. When that device calls `alarmStatus`, it gets:
```
HTTP/1.1 409 Conflict
```
```json
{
"error": { "code": "ERR_ALARM_TRIGGERED_OFFLINE", "message": "…" },
"alarm": {
"type": "alarm",
"alertId": "0f8fad5b-…",
"eventId": "…",
"action": "trigger",
"message": "Leave the building now",
"serverTime": "2026-10-18T17:05:00.123Z"
}
}
```
**This is a success-shaped alarm signal — the HTTP status is `409` but the
response means "ring", never "the request failed".** `error` is the platform
envelope verbatim (do not expect frame fields inside it, and there is no third
top-level key): the frame lives in the sibling `alarm` object, shaped exactly
like the WebSocket frame, so one parser serves both paths. A client that renders
this as an error has failed to deliver the alarm. Handle `alarm` exactly like a
live `trigger` frame: max volume, wake the screen, `ack`, and stop on a disarm.
The socket is the primary path now: the same ledger is replayed on WebSocket
connect (see "Reconnect catch-up"), which reaches the native service even when
the JS layer is not running. The 409 is the secondary path for a client that is
running and polling but has no socket — both settle the same rows.
- The rows are marked **delivered** (`delivered_at`) by the act of returning this
response, so the same uninterrupted alarm cannot produce a second `409`. That
is a receipt, not a dismissal: it does not end the alert.
- On the live path the WebSocket `ack` frame records the same receipt, so an
online client that actually received the frame is never told again.
- Everything is idempotent: an ack for an event the device never owed, or a
repeated ack, updates nothing and is not an error.
- A trigger that was already disarmed before the device checked back in is not
news: the normal body (`active: false`) is returned instead of a stale ring.
- `?deviceId=` selects the subscription (raw value, as sent to `joinAlert`);
without it the caller's oldest subscription on the alert is used, mirroring the
socket. The service hashes it before comparing.
- If several triggers were missed, all pending entries are retired at once and
the newest missed event is the one returned — a device is not made to ring once
per missed event.
Errors: `ERR_VALIDATION`, `ERR_ALERT_NOT_FOUND`, `ERR_ALERT_NOT_SUBSCRIBED`,
`ERR_UNAUTHORIZED`.
## `ackAlarm`
**POST** `/action/ackAlarm` — the explicit dismissal of a ringing alarm. The
device rides the payload (or the query string), exactly like `joinAlert`:
```json
{ "payload": { "alertId": "…", "eventId": "…", "deviceId": "pixel-7" } }
{ "payload": { "alertId": "…", "eventId": "…" } } // device token in X-Device-Token
{ "payload": { "alertId": "…" } } // dismiss everything owed
```
Response `200`:
```json
{ "alertId": "…", "eventId": "…", "acked": 1, "destroyed": false }
// with no eventId: "eventId": "" and acked counts every row that was cleared
```
- Clearing is **ack-driven**, never response-driven: `alarmStatus` only *reports*
debt (409 + frame) and clears nothing, so a 409 lost in transit does not
forfeit the alarm — the next check-in finds the same row.
- **Receipt and dismissal are different facts.** A client acks a frame as soon as
it receives it (that is how an online device stops being told about the same
alarm on every poll). That writes `delivered_at`, which retires re-delivery but
is *not* a dismissal. Only `ackAlarm` writes `acked_at`, and only `acked_at`
counts towards the drain-to-empty rule — so the alert survives while phones are
still ringing even when everyone happened to be online.
- Idempotent: a repeat ack (or an ack for an event the device never owed) returns
`acked: 0`, never an error. The WebSocket `ack` frame does NOT dismiss an
alarm — it only records the receipt.
- `acked` counts the ledger rows dismissed for this device. When the last
un-dismissed row of the alert is dismissed, the alert drains and is destroyed
(reason `delivered`) — `destroyed: true` reports that intended outcome. A
receipt (the WS `ack` frame) never counts here: it only sets `delivered_at`.
The drain check runs on every explicit dismissal, even when this call cleared
no row, because a receipted row is still undismissed. Note that a
destroyed alert takes its subscriptions with it, so a device token for it stops
resolving (the next call is `401 ERR_UNAUTHORIZED`, not `404`); clients must
treat that as "the subscription is over", not as a retryable failure.
- **`eventId` is optional.** Without it the dismissal retires the caller's whole
outstanding set on the alert. That exists because the native takeover launch
carries only the alert: a client that dismissed an alarm without being able to
name its event must still be able to clear the debt, or the replay loop it
could not describe keeps ringing forever.
- The alert owner has no delivery row, so an owner ack is a well-formed no-op.
- Subscribers must pass `deviceId` (raw; the server hashes it). A device can only
clear its own row.
Errors: `ERR_VALIDATION`, `ERR_ALERT_NOT_FOUND`, `ERR_ALERT_NOT_SUBSCRIBED`, Errors: `ERR_VALIDATION`, `ERR_ALERT_NOT_FOUND`, `ERR_ALERT_NOT_SUBSCRIBED`,
`ERR_UNAUTHORIZED`. `ERR_UNAUTHORIZED`.
@@ -457,7 +724,8 @@ Errors: `ERR_VALIDATION`, `ERR_ALERT_NOT_FOUND`, `ERR_ALERT_NOT_SUBSCRIBED`,
{ "payload": { "alertId": "…", "adminToken": "…" } } { "payload": { "alertId": "…", "adminToken": "…" } }
``` ```
Deletes the alert, its subscriptions and its event history (cascade). Response Deletes the alert, its subscriptions, its event history and its delivery-ledger
rows (all `ON DELETE CASCADE` in the schema). Response
`200`: `200`:
```json ```json
@@ -496,30 +764,6 @@ Keep the quiet zone at ≥ 4 modules; scanners reject codes without it.
Errors: `ERR_VALIDATION`, `ERR_ALERT_NOT_FOUND`, `ERR_ALERT_NOT_SUBSCRIBED`, Errors: `ERR_VALIDATION`, `ERR_ALERT_NOT_FOUND`, `ERR_ALERT_NOT_SUBSCRIBED`,
`ERR_UNAUTHORIZED`, `ERR_INTERNAL` (encoder failure — never expected). `ERR_UNAUTHORIZED`, `ERR_INTERNAL` (encoder failure — never expected).
## `rotateSubscribeToken`
Invalidates the current QR and mints a new subscribe token. Existing
subscriptions keep working; only *joining* with the old token stops working.
Use it when the QR leaked.
**POST** `/action/rotateSubscribeToken`
```json
{ "payload": { "alertId": "…", "adminToken": "…" } }
```
Response `200`:
```json
{ "alertId": "…", "subscribeToken": "…",
"qrPayload": "https://alarm.dcntrlized.org/a/…#…",
"tokenVersion": 2 }
```
Errors: as `triggerAlert`.
---
## QR payload format ## QR payload format
The QR encodes exactly the `qrPayload` field: The QR encodes exactly the `qrPayload` field:
@@ -543,12 +787,24 @@ to a server by a browser — the token stays client-side until the app posts it
## Live protocol — `GET /ws/alarm` ## Live protocol — `GET /ws/alarm`
The service path is:
``` ```
ws://<host>:8088/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>
``` ```
The socket is **authenticated, not anonymous**: the access token identifies the Through an instance (the supported path) that is
user and the second token proves the relationship to the alert. `wss://<instance>/api/alarm/ws/alarm?…`; on the dev host the raw service port is
`ws://localhost:8088/ws/alarm?…`. The data plane relays control frames end to
end, so the 20s ping / 60s pong accounting below is measured between client and
service, not against the proxy.
The socket is **authenticated, not anonymous**: one of two credentials
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
@@ -586,16 +842,13 @@ from one fan-out:
"eventId": "…", "eventId": "…",
"action": "trigger", // "trigger" | "disarm" "action": "trigger", // "trigger" | "disarm"
"message": "Leave the building now", "message": "Leave the building now",
"serverTime": "2026-10-18T17:05:00.123Z", "serverTime": "2026-10-18T17:05:00.123Z"
"autoStopSeconds": 300
} }
``` ```
`ack` — the server's acknowledgement of a client ack: There is **no** server → client `ack` frame: `ack` is client → server only (it
counts the acknowledgement and clears the durable delivery-ledger row). A client
```json must not wait for one.
{ "type": "ack", "eventId": "…" }
```
### Client → server frames ### Client → server frames
@@ -633,11 +886,43 @@ candidate; the same applies to sending non-JSON frames.
### Reconnect catch-up ### Reconnect catch-up
The service keeps the **last event per alert** in memory. On connect, if that Two replays happen on a successful upgrade, both before anything else is sent:
event is still active (a `trigger` whose auto-stop window has not expired), the
server sends it immediately — so a client that reconnects mid-alarm fires 1. **Durable ledger replay (the authoritative one).** Every trigger writes a
without polling, and a client that reconnects *after* the alarm is over is never delivery-ledger row per enabled subscription. When a socket connects, the
re-alarmed. service sends **every trigger frame this exact `(alert, subscription)` still
owes**, oldest first — up to a bounded handful. This is what makes delivery
survive being offline even when the client's JS layer is dead: the Android
foreground service holds the socket, receives the frame, and schedules the
alarm clock without any app-level check-in. Nothing is marked by the replay
itself: the debt clears only when the client `ack`s (or the `alarmStatus`
check-in settles it), so a frame the client never processed stays owed and is
sent again on the next connect.
2. **In-memory last-event catch-up (the fast path).** The service also keeps the
last event per alert in memory and replays it if it is still live (a
`trigger` not superseded by a disarm). This is belt-and-braces: it covers the
window before/while the ledger row is visible, and it is what the duplicate
suppression below is about — if the ledger replay already sent that exact
`eventId`, the catch-up is skipped.
A trigger that was already **disarmed** before the device reconnects is not
replayed — ringing a stale alarm would be worse than silence; the check-in
clears that debt instead.
Honest limits: the in-memory catch-up is per-instance, so on an instance that
never saw the trigger it does nothing; the ledger is what crosses instances and
restarts. And replay and a live `NOTIFY` can race, so a client may legitimately
see the same `eventId` twice — acks are keyed by `eventId` and ledger clearing is
idempotent, so it is a duplicate, not a fault.
### Deployment constraint — `DEV_MODE_STATIC_SERVICES`
The Maestro data plane (`/api/<service>/…`) resolves the target through
`serviceproxy.Resolve`, which always goes through the Docker client. Under
Maestro's `DEV_MODE_STATIC_SERVICES` there are no containers to resolve, so the
data plane cannot work in that mode; a real instance always has Docker. This is a
platform constraint, not an Alarm limitation — direct service addressing is not a
supported deployment.
### Multiple instances ### Multiple instances
@@ -651,15 +936,16 @@ subscribers of instance B as well. Duplicate deliveries are suppressed by
### Minimal client ### Minimal client
```js ```js
// Through an instance: wss://<instance>/api/alarm/ws/alarm?…
const ws = new WebSocket( const ws = new WebSocket(
`ws://alarm:8088/ws/alarm?alertId=${alertId}&token=${subscribeToken}` + `wss://${instance}/api/alarm/ws/alarm?alertId=${alertId}` +
`&accessToken=${accessToken}&deviceId=${deviceId}`); `&token=${subscribeToken}&accessToken=${accessToken}&deviceId=${deviceId}`);
ws.onmessage = (ev) => { ws.onmessage = (ev) => {
const f = JSON.parse(ev.data); const f = JSON.parse(ev.data);
if (f.type === "alarm" && f.action === "trigger") { if (f.type === "alarm" && f.action === "trigger") {
// autoStopSeconds: 0 = ring until a disarm arrives; >0 = self-stop after N // No auto-stop: ring until a disarm frame (or a local dismissal) arrives.
ring(f.message, f.eventId, f.autoStopSeconds); // max volume, wake screen ring(f.message, f.eventId); // max volume, wake screen
ws.send(JSON.stringify({ type: "ack", eventId: f.eventId })); ws.send(JSON.stringify({ type: "ack", eventId: f.eventId }));
} }
if (f.type === "alarm" && f.action === "disarm") stopRinging(f.eventId); if (f.type === "alarm" && f.action === "disarm") stopRinging(f.eventId);
@@ -675,6 +961,19 @@ like a live `trigger` frame, and `active: false` (or `action: "disarm"`) as the
stop signal. Always prefer the socket: it is the < 1s path, and polling only stop signal. Always prefer the socket: it is the < 1s path, and polling only
bounds the worst case. bounds the worst case.
Polling rides the same instance path as everything else:
`GET {base}/api/alarm/action/alarmStatus?alertId=…` with the same access token —
the data plane forwards the query string verbatim, so the exact same URL the
socket uses for its identity works here. There is no separate "mode": the socket
and the poll are two transports over one address.
The honest failure story is therefore **"the socket is not listening"**, not
"the wrong mode": the phone cannot reach the instance at all (no network, wrong
instance URL, service stopped — the relay answers `409 ERR_NOT_RUNNING` while
the instance itself is up), or the socket keeps dropping. Polling bounds the
worst case in every one of those; it never turns a reachable instance into an
unreachable one.
--- ---
## `GET /_manifest` ## `GET /_manifest`
@@ -699,6 +998,8 @@ mount the shared-auth proxy.
## Worked example ## Worked example
```bash ```bash
# Through an instance (the supported path) the base is https://<instance>/api/alarm.
# On the dev host you can talk to the service directly for debugging:
BASE=http://localhost:8088 BASE=http://localhost:8088
# 1. access token (shared auth) # 1. access token (shared auth)
@@ -709,7 +1010,7 @@ TOKEN=$(curl -s -X POST "$BASE/auth/login" \
# 2. create the alert (keep adminToken!) # 2. create the alert (keep adminToken!)
curl -s -X POST "$BASE/action/createAlert" -H "X-Access-Token: $TOKEN" \ curl -s -X POST "$BASE/action/createAlert" -H "X-Access-Token: $TOKEN" \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-d '{"payload":{"name":"Fire drill","message":"Leave the building","autoStopSeconds":300}}' | jq -d '{"payload":{"name":"Fire drill","message":"Leave the building"}}' | jq
# 3. a second user scans the QR payload and joins # 3. a second user scans the QR payload and joins
curl -s -X POST "$BASE/action/joinAlert" -H "X-Access-Token: $BOB" \ curl -s -X POST "$BASE/action/joinAlert" -H "X-Access-Token: $BOB" \
@@ -728,6 +1029,12 @@ curl -s -X POST "$BASE/action/triggerAlert" -H "X-Access-Token: $TOKEN" \
curl -s -X POST "$BASE/action/disarmAlert" -H "X-Access-Token: $TOKEN" \ curl -s -X POST "$BASE/action/disarmAlert" -H "X-Access-Token: $TOKEN" \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-d '{"payload":{"alertId":"'$ALERT'","adminToken":"'$ADMIN'"}}' | jq -d '{"payload":{"alertId":"'$ALERT'","adminToken":"'$ADMIN'"}}' | jq
# 7. what a device that was offline during the trigger sees when it checks in.
# First call: 409 ERR_ALARM_TRIGGERED_OFFLINE + the alarm frame. Second call:
# a normal 200 with active:false — the ledger row was cleared by the first.
curl -si -H "X-Access-Token: $BOB" \
"$BASE/action/alarmStatus?alertId=$ALERT&deviceId=pixel-7" | head -20
``` ```
## Storage ## Storage
@@ -736,9 +1043,75 @@ All state lives in the shared PostgreSQL database, schema `alarm`:
| Table | Contents | | Table | Contents |
| --- | --- | | --- | --- |
| `alarm.alerts` | one row per alert: name, message, admin user id, admin-token hash, subscribe token + version, status, timestamps, `auto_stop_seconds` | | `alarm.alerts` | one row per alert: name, message, admin user id, admin-token hash, subscribe token, status, timestamps, `expires_at` (owner-set persistence cap, hard maximum 14 days). No auto-stop/ringing-duration column exists. |
| `alarm.subscriptions` | `(alert, user, device)` unique, label/platform, `enabled`, `last_seen_at` | | `alarm.subscriptions` | `(alert, user, sha256(device id))` unique, label/platform, `enabled`, `last_seen_at`. `device_id` stores `sha256:<64 lowercase hex>`, never the raw client value. |
| `alarm.alarm_events` | append-only trigger/disarm history; its `AFTER INSERT` trigger issues the `pg_notify` that fans out to other instances | | `alarm.alarm_events` | append-only trigger/disarm history; its `AFTER INSERT` trigger issues the `pg_notify` that fans out to other instances |
| `alarm.alarm_deliveries` | the durable per-device delivery ledger: `(alert, subscription, event)` primary key, `created_at`, nullable `acked_at`. Written in the trigger transaction, one row per enabled subscription; cascades with its alert and subscription. It is what makes an offline device's missed alarm recoverable and what makes "no auto-stop" safe. |
Migrations live in `database/migrations/` and are applied by the compose Migrations live in `database/migrations/` and are applied by the compose
`migrate` one-shot; they are idempotent and safe to re-apply. `migrate` one-shot; they are idempotent and safe to re-apply.
### What the device-id hash does and does not buy
`sha256:<hex>` makes a database dump useless as a list of device identifiers and
it means the raw value never sits at rest. It is **not** a credential (it never
authenticates anything — identity comes from the access token) and it is **not**
an unlinkability guarantee: the same raw id on the same alert always maps to the
same digest, and that correlation is the whole point, because it is what makes a
re-join idempotent. If two people share one device, they are one device for this
purpose by design.
## Validation and the persistence cap
`createAlert` accepts an optional `expiresInSeconds` (integer, seconds):
| Bound | Value | Why |
| --- | --- | --- |
| default (field omitted) | **24 h** (`86400`) | an alert is a transient safety mechanism; a day covers "created tonight, triggered tomorrow morning" without leaving debris for a week |
| minimum | **60 s** | the sweeper ticks every ~60 s; anything shorter could expire before a device can join |
| maximum | **14 days** (`1209600`) | the user's hard cap; values above are `ERR_VALIDATION`, never silently clamped, because a silent clamp changes what the owner asked for |
`expiresAt` (RFC 3339) is returned by `createAlert` and by every `alertView`.
An alert past its instant reads as `ERR_ALERT_NOT_FOUND` on every action —
including `alarmStatus` — even before the sweeper reaches it, so a client cannot
observe a zombie.
## The event log
Exactly one durable, anonymised lifecycle table: **`alarm.event_log`**.
| Column | Type | Meaning |
| --- | --- | --- |
| `id` | `BIGSERIAL` | primary key |
| `occurred_at` | `TIMESTAMPTZ NOT NULL DEFAULT now()` | when |
| `event` | `TEXT NOT NULL` | `created`, `joined`, `triggered`, `delivered`, `destroyed` |
| `alert_hash` | `TEXT NOT NULL` | SHA-256 hex of the alert UUID |
| `user_hash` | `TEXT` | SHA-256 hex of the actor/subscriber UUID |
| `reason` | `TEXT` | `destroyed` rows only: `delivered`, `expired`, `disarmed`, `deleted` |
It records **important lifecycle events only**. It is **not** a mirror of stdout:
the per-request access log stays on stdout. It has deliberately **no** column for
an IP, a user agent, a token, a request body, a query string or a request
identifier of any kind, and adding one later would be a privacy regression.
Anonymisation, two schemes on purpose:
- `alert_hash` / `user_hash`: **plain SHA-256** over the canonical UUID string. A
UUID carries ~122 random bits, so a plain digest is genuinely one-way.
- The stdout request line hashes the client IP and user agent with the same plain
SHA-256. Honest limitation: an IPv4 address is a 2^32 space, so a plain digest
is enumerable — that is anonymisation of *records* (a log dump does not
directly name a client), **not** cryptographic protection. Do not add a pepper,
a salt or a work factor; the user rejected that as overengineering, and the
network identifier never reaches durable storage anyway.
No foreign keys: rows survive the alert they describe by construction.
### Retention
- `event_log`: **age-based only — 7 days**, swept by the same ~60 s goroutine as
expiry (`DELETE … WHERE occurred_at < now() - interval '7 days'`). This is the
fastest-growing table in the schema; it must never grow without bound.
- Alerts: destroyed at `expires_at` (or by drain/disarm/delete), never by age
alone without the owner having chosen `expiresInSeconds`.
- Delivery ledger rows: removed only with their alert/subscription cascade.
@@ -0,0 +1,52 @@
\c dcntrlized;
-- 005_alarm_deliveries.sql — a durable per-device delivery ledger.
--
-- Why this exists: pg_notify and the WebSocket fan-out are not durable. A phone
-- that is offline at trigger time misses the frame forever, and the in-memory
-- catch-up cache only lives in the process that produced the event. This table is
-- the durable record of who still owes an alarm, so "no auto-stop" is safe: the
-- alarm stays live until the owner disarms it, and every subscribed device has a
-- row here until it has been told.
--
-- Shape: one row per (alert, subscription, event), written in the SAME
-- transaction as the alarm_events insert and therefore before the NOTIFY on
-- commit. If this insert fails the trigger fails — a fan-out that records no debt
-- would be exactly the silent failure this design prevents.
--
-- Cascades are in the schema, not in application code: deleting an alert or a
-- subscription removes its ledger rows. event_id deliberately has no foreign key:
-- the ledger is the record of what a device owes, and it must outlive nothing but
-- its alert/subscription (both of which cascade).
--
-- Idempotent (same guard pattern as 002/003/004): a re-apply of the whole
-- sequence is a clean no-op.
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'alarm') THEN
RETURN;
END IF;
CREATE TABLE IF NOT EXISTS alarm.alarm_deliveries (
alert_id UUID NOT NULL REFERENCES alarm.alerts(id) ON DELETE CASCADE,
subscription_id UUID NOT NULL REFERENCES alarm.subscriptions(id) ON DELETE CASCADE,
event_id UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
acked_at TIMESTAMPTZ,
PRIMARY KEY (alert_id, subscription_id, event_id)
);
-- The check-in reads by alert + subscription; the pending index keeps
-- "who still owes this alert" a small partial scan instead of a table scan.
CREATE INDEX IF NOT EXISTS alarm_deliveries_alert_idx
ON alarm.alarm_deliveries (alert_id);
CREATE INDEX IF NOT EXISTS alarm_deliveries_pending_idx
ON alarm.alarm_deliveries (alert_id, subscription_id)
WHERE acked_at IS NULL;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'alarm_user') THEN
GRANT SELECT, INSERT, UPDATE, DELETE ON alarm.alarm_deliveries TO alarm_user;
END IF;
END
$$;
@@ -0,0 +1,48 @@
\c dcntrlized;
-- 007_alarm_analytics.sql — anonymized, append-only lifecycle counters.
--
-- User request: "anonymized 'Alarm X created by Y', 'Alarm X subscribed by Y',
-- 'Alarm X delivered/triggered to/by Y', along with timestamps". Nothing raw, no
-- foreign keys, and it must survive the alert it describes — hence no REFERENCES
-- and no cascade. A destroyed alert still has its history here.
--
-- Identity columns are SHA-256 hex of the UUID *string* form. That is genuinely
-- one-way (a UUID carries ~122 random bits, so there is no address space to
-- enumerate), which is why this file uses a plain digest while the request-log
-- IP/UA hashing uses HMAC with a secret key: an IPv4 address is only 2^32 and a
-- plain digest would be reversible by lookup. Do not "unify" the two schemes.
--
-- `reason` is set only for `destroyed` rows: `delivered` (the ledger drained) or
-- `expired` (the persistence cap fired).
--
-- Idempotent (same guard pattern as 002–006).
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'alarm') THEN
RETURN;
END IF;
CREATE TABLE IF NOT EXISTS alarm.alarm_analytics (
id BIGSERIAL PRIMARY KEY,
alert_hash TEXT NOT NULL,
user_hash TEXT,
action TEXT NOT NULL,
reason TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT alarm_analytics_action_check
CHECK (action IN ('created', 'subscribed', 'triggered', 'delivered', 'destroyed'))
);
CREATE INDEX IF NOT EXISTS alarm_analytics_alert_idx
ON alarm.alarm_analytics (alert_hash, created_at);
CREATE INDEX IF NOT EXISTS alarm_analytics_created_idx
ON alarm.alarm_analytics (created_at);
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'alarm_user') THEN
GRANT SELECT, INSERT ON alarm.alarm_analytics TO alarm_user;
GRANT USAGE, SELECT ON SEQUENCE alarm.alarm_analytics_id_seq TO alarm_user;
END IF;
END
$$;
+56
View File
@@ -0,0 +1,56 @@
\c dcntrlized;
-- 008_log_events.sql — durable, anonymised service log.
--
-- User request (2026-10-18): the service should keep its operational log in the
-- database, not only on stdout, and it must not keep raw IPs or user agents.
-- Explicit columns end in `_hash` so a future reader cannot insert a raw value
-- by accident, and there is deliberately NO column for a token, request body or
-- query string: those must never be persisted (in Alarm they carry admin and
-- subscribe tokens).
--
-- Identifier hashing is NOT the analytics scheme:
-- * alert_hash / user_hash: SHA-256 hex of the UUID (analytics scheme; UUIDs
-- carry ~122 random bits, so a plain digest is one-way).
-- * ip_hash / ua_hash: Argon2id with a service-generated public salt and a
-- tuned work factor (internal/loghash). IPs are a 2^32 space, so a plain
-- digest is a lookup table away from being reversed; only the KDF's cost
-- protects them. The two schemes are intentionally different — do not
-- "unify" them, and do not lower the Argon2id parameters to make the logger
-- faster: that would silently un-anonymise every historical row.
--
-- All columns are nullable except id/occurred_at/level/kind/message: rows are
-- anonymised by construction, so this table can be exported for support without
-- leaking who or where. Retention is enforced by the sweeper (age + row cap),
-- see API.md; the table must never grow without bound.
--
-- Idempotent (same guard pattern as 002–007).
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'alarm') THEN
RETURN;
END IF;
CREATE TABLE IF NOT EXISTS alarm.log_events (
id BIGSERIAL PRIMARY KEY,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
level TEXT NOT NULL,
kind TEXT NOT NULL,
message TEXT NOT NULL,
ip_hash TEXT,
ua_hash TEXT,
alert_hash TEXT,
user_hash TEXT,
CONSTRAINT log_events_level_check CHECK (level IN ('debug', 'info', 'warn', 'error'))
);
CREATE INDEX IF NOT EXISTS log_events_occurred_idx ON alarm.log_events (occurred_at);
CREATE INDEX IF NOT EXISTS log_events_kind_idx ON alarm.log_events (kind, occurred_at);
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'alarm_user') THEN
GRANT SELECT, INSERT, DELETE ON alarm.log_events TO alarm_user;
GRANT USAGE, SELECT ON SEQUENCE alarm.log_events_id_seq TO alarm_user;
END IF;
END
$$;
@@ -0,0 +1,83 @@
\c dcntrlized;
-- 009_consolidate_event_log.sql — one event table, not two.
--
-- History: 007 created alarm.alarm_analytics (lifecycle rows with a reason) and
-- 008 created alarm.log_events (a durable mirror of stdout with hashed
-- ip/ua). The user ruling of 2026-10-18 superseded both: ONE durable table for
-- important lifecycle events only, with NO network identifiers, NO level, NO
-- message body, NO request metadata. stdout keeps the request log; this table is
-- not a mirror of it.
--
-- Final table: alarm.event_log
-- id BIGSERIAL primary key
-- occurred_at TIMESTAMPTZ NOT NULL DEFAULT now()
-- event TEXT NOT NULL -- created | joined | triggered | delivered | destroyed
-- alert_hash TEXT NOT NULL -- SHA-256 hex of the alert UUID
-- user_hash TEXT -- SHA-256 hex of the actor/subscriber UUID
-- reason TEXT -- on 'destroyed' only: delivered|expired|disarmed|deleted
--
-- Identities are plain SHA-256 of the UUID string (UUIDs carry ~122 random bits,
-- so a digest is one-way). There is deliberately no column for an IP, a user
-- agent, a token, a body or a query string, and adding one later would be a
-- privacy regression, not a feature.
--
-- This migration:
-- 1. creates alarm.event_log (if missing);
-- 2. copies any rows from the two superseded tables, mapping their vocabularies;
-- 3. drops both superseded tables.
--
-- It is IDEMPOTENT IN EFFECT for a full 001→009 re-apply: re-applying 007/008
-- recreates the old tables empty, this file copies nothing and drops them again,
-- so the sequence always ENDS with exactly one table (alarm.event_log).
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'alarm') THEN
RETURN;
END IF;
CREATE TABLE IF NOT EXISTS alarm.event_log (
id BIGSERIAL PRIMARY KEY,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
event TEXT NOT NULL,
alert_hash TEXT NOT NULL,
user_hash TEXT,
reason TEXT,
CONSTRAINT event_log_event_check
CHECK (event IN ('created', 'joined', 'triggered', 'delivered', 'destroyed'))
);
CREATE INDEX IF NOT EXISTS event_log_alert_idx ON alarm.event_log (alert_hash, occurred_at);
CREATE INDEX IF NOT EXISTS event_log_occurred_idx ON alarm.event_log (occurred_at);
IF to_regclass('alarm.alarm_analytics') IS NOT NULL THEN
INSERT INTO alarm.event_log (occurred_at, event, alert_hash, user_hash, reason)
SELECT created_at,
CASE action
WHEN 'created' THEN 'created'
WHEN 'subscribed' THEN 'joined'
WHEN 'triggered' THEN 'triggered'
WHEN 'delivered' THEN 'delivered'
ELSE 'destroyed'
END,
alert_hash,
user_hash,
reason
FROM alarm.alarm_analytics
ORDER BY id;
DROP TABLE alarm.alarm_analytics;
END IF;
-- log_events held hashed network identifiers and stdout mirrors; the user
-- ruling removed both, so its rows are intentionally discarded.
IF to_regclass('alarm.log_events') IS NOT NULL THEN
DROP TABLE alarm.log_events;
END IF;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'alarm_user') THEN
GRANT SELECT, INSERT, DELETE ON alarm.event_log TO alarm_user;
GRANT USAGE, SELECT ON SEQUENCE alarm.event_log_id_seq TO alarm_user;
END IF;
END
$$;
+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 $$;
@@ -0,0 +1,55 @@
-- 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/>.
-- 011_delivery_receipts.sql — "received" is not "dismissed".
--
-- The delivery ledger had one timestamp (`acked_at`) for two different facts,
-- and the clients ack a frame the moment they receive it (that is how a live
-- device stops being told about the same alarm on every poll). Because the
-- drain-to-empty rule destroys the alert when every row is acked, a single-device
-- alert was destroyed 27 ms after the owner triggered it — measured on an
-- Android 15 emulator: `triggered` then `destroyed/delivered` in the event log,
-- while the phone was still ringing. The owner then could not disarm
-- (`ERR_ALERT_NOT_FOUND`), the subscription went with the alert, and its device
-- token stopped resolving mid-alarm (401).
--
-- So the ledger now records both facts:
-- * `delivered_at` — the device has the trigger (a live frame, a replay, or a
-- check-in). It stops re-delivery. It is NOT a dismissal.
-- * `acked_at` — the person holding the device dismissed it. Only this drives
-- the drain, so the alert ends when everyone has dismissed it, never because
-- everyone happened to be online.
--
-- Additive and idempotent. Existing rows: a row with `acked_at` set keeps that
-- fact (it was a dismissal under the old contract too); a pending row simply has
-- no receipt yet, which is the correct reading of "we never recorded a delivery".
\c dcntrlized
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'alarm') THEN
RETURN;
END IF;
ALTER TABLE alarm.alarm_deliveries ADD COLUMN IF NOT EXISTS delivered_at TIMESTAMPTZ;
-- "Who still owes an alarm on this device" is the check-in query; the
-- partial index keeps it to the rows that can still answer yes.
DROP INDEX IF EXISTS alarm.alarm_deliveries_pending_idx;
CREATE INDEX IF NOT EXISTS alarm_deliveries_undelivered_idx
ON alarm.alarm_deliveries (alert_id, subscription_id)
WHERE acked_at IS NULL AND delivered_at IS NULL;
END $$;
+14 -2
View File
@@ -12,6 +12,15 @@ services:
image: alarm:debug image: alarm:debug
container_name: alarm-debug container_name: alarm-debug
# Discovery labels — exactly what Maestro's install path injects
# (`lib/containers/compose.go`): the service name and the port it listens on
# inside `dcntrlized_net`. Without them the single data plane answers
# `unknown service "alarm"` and a phone cannot reach this service through
# the only supported path, so the dev stack would be invisible to it.
labels:
org.dcntrlized.service: "alarm"
org.dcntrlized.port: "8080"
# Cold `docker compose -f docker-compose.debug.yaml up -d` builds this image # Cold `docker compose -f docker-compose.debug.yaml up -d` builds this image
# directly — no pre-build step needed. Do NOT add `network: host` here: # directly — no pre-build step needed. Do NOT add `network: host` here:
# `docker compose up`/`build` cannot grant the `network.host` build # `docker compose up`/`build` cannot grant the `network.host` build
@@ -57,8 +66,11 @@ services:
# longer gates any listener — this listener is ALWAYS plaintext now. # longer gates any listener — this listener is ALWAYS plaintext now.
- DCNTRLIZED_DEBUG=true - DCNTRLIZED_DEBUG=true
# Shared auth service target (dcntrlized-auth on the dcntrlized network) # Shared auth service target (dcntrlized-auth on the dcntrlized network).
- AUTH_SERVICE_URL=http://dcntrlized-auth:8080 # Overridable because the dev fleet runs the hub as `auth-debug`
# (`dcntrlized-auth/docker-compose.debug.yaml`) rather than under its
# production DNS name, and an alarm nobody can log in to is untestable.
- AUTH_SERVICE_URL=${AUTH_SERVICE_URL:-http://dcntrlized-auth:8080}
# Service identity (inert in v1 — no /config edge — kept for fleet parity) # Service identity (inert in v1 — no /config edge — kept for fleet parity)
- DCNTRLIZED_SERVICE_NAME=alarm - DCNTRLIZED_SERVICE_NAME=alarm
+194
View File
@@ -0,0 +1,194 @@
// 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 (
"net/http"
"strings"
"git.dcntrlized.org/Dcntrlized/Alarm/db"
"git.dcntrlized.org/Dcntrlized/Alarm/internal/deviceid"
"github.com/google/uuid"
)
// ackAlarmRequest is the HTTP dismissal of a ringing alarm. The device rides the
// payload (or the query string) exactly like joinAlert's, so the caller is
// addressed by (alert, user, device) and can only clear its own debt.
type ackAlarmRequest struct {
AlertID string `json:"alertId"`
EventID string `json:"eventId"`
DeviceID string `json:"deviceId"`
}
type ackAlarmResponse struct {
AlertID string `json:"alertId"`
EventID string `json:"eventId"`
Acked int64 `json:"acked"`
Destroyed bool `json:"destroyed"`
}
// AckAlarm implements POST /action/ackAlarm.
//
// User ruling: "On client dismiss of a triggered alarm, a request is sent back to
// the server ACKing the alarm, that's what triggers the clear." Clearing is
// therefore ack-driven and never response-driven: the alarmStatus check-in is
// read-only, and this action (or the WebSocket `ack` frame) is what retires the
// delivery-ledger row.
//
// Idempotent: the second ack for the same event clears nothing, returns
// acked:0, and is not an error. When the ack clears the alert's last unacked
// row, the alert is destroyed (reading A) and `destroyed:true` reports it — the
// alert no longer exists, which is the intended outcome, not a failure.
func AckAlarm(w http.ResponseWriter, r *http.Request, payload any) {
const action = "ackAlarm"
c, ok := callerFor(w, r, action)
if !ok {
return
}
alertID, err := alertIDFrom(r, payload)
if err != nil {
validationError(w, "%v", err)
return
}
// eventId is optional on purpose. The native takeover launch carries only the
// alert, so a dismissal from that path has no event to name; "I have heard
// this device's alarm" is then answered by clearing the caller's whole
// outstanding set (see db.AckAllDeliveries). A stale debt that nobody can
// name must not replay forever because the client could not echo a uuid.
var eventID uuid.UUID
if eventRaw := fieldOrQuery(r, payload, "eventId"); eventRaw != "" {
parsed, err := uuid.Parse(eventRaw)
if err != nil {
validationError(w, "eventId is not a valid uuid")
return
}
eventID = parsed
}
alert, err := db.GetAlert(r.Context(), alertID)
if err != nil {
dbError(w, action, err)
return
}
if c.IsDevice {
if !c.subscribedTo(alertID) {
writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "not subscribed to this alert")
return
}
} else {
allowed, err := isAdminOrSubscriber(r, alert, c.UserID)
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()}
if eventID != uuid.Nil {
resp.EventID = eventID.String()
}
// A device token lives on the subscription, so the ledger row is addressed
// directly: no user id, no device-id echo, nothing to spoof.
if c.IsDevice {
var cleared int64
var err error
if eventID == uuid.Nil {
cleared, err = db.AckAllDeliveries(r.Context(), alertID, c.Sub.ID)
} else {
cleared, err = db.AckDelivery(r.Context(), alertID, c.Sub.ID, eventID)
}
if err != nil {
dbError(w, action, err)
return
}
resp.Acked = cleared
if cleared > 0 {
if err := db.TouchSubscription(r.Context(), c.Sub.ID); err != nil {
dbError(w, action, err)
return
}
}
// The drain check runs on every explicit dismissal, not only when this
// call cleared a row: the receipt ack marks a row delivered but not
// dismissed, so the person dismissing is what completes the set even
// when there was nothing left for THIS call to clear.
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 all=%t acked=%d destroyed=%t (device)",
alertID, resp.EventID, eventID == uuid.Nil, 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
}
deviceHash := deviceid.Hash(raw)
var cleared int64
if eventID == uuid.Nil {
cleared, err = db.AckAllForSubscriber(r.Context(), alertID, c.UserID, deviceHash)
} else {
cleared, err = db.AckAlarmDelivery(r.Context(), alertID, c.UserID, deviceHash, 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)
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 all=%t acked=%d destroyed=%t",
alertID, resp.EventID, eventID == uuid.Nil, resp.Acked, resp.Destroyed)
writeJSON(w, http.StatusOK, resp)
}
// fieldOrQuery reads a string from the payload and falls back to the query
// string, matching how the other actions accept both transports.
func fieldOrQuery(r *http.Request, payload any, field string) string {
if v := strings.TrimSpace(r.URL.Query().Get(field)); v != "" {
return v
}
if payload == nil {
return ""
}
return strings.TrimSpace(lenientString(payload, field))
}
+12 -3
View File
@@ -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,9 +296,17 @@ 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 {
// The device token names exactly one subscription, so "subscribed"
// is a field comparison rather than a query.
if !c.subscribedTo(alertID) {
writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "not subscribed to this alert")
return
}
} else {
allowed, err := db.IsSubscriber(r.Context(), alertID, c.UserID)
if err != nil { if err != nil {
dbError(w, action, err) dbError(w, action, err)
return return
@@ -308,6 +316,7 @@ func GetAlert(w http.ResponseWriter, r *http.Request, payload any) {
return return
} }
} }
}
count, err := db.CountSubscriptions(r.Context(), alertID) count, err := db.CountSubscriptions(r.Context(), alertID)
if err != nil { if err != nil {
dbError(w, action, err) dbError(w, action, err)
+151
View File
@@ -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")
}
}
+50
View File
@@ -0,0 +1,50 @@
// 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 (
"context"
"errors"
"github.com/google/uuid"
)
// completeAlertDelivery is the decision point for the second half of the
// delivery design: "once every client is removed for an alarm, the alarm is then
// deleted with whichever cascade deletions are relevant".
//
// It is DELIBERATELY UNWIRED — nothing calls it. That sentence has two readings
// and they are different products, so the user has been asked which one is meant:
//
// (a) delete the ALERT. The definition, both tokens, the subscriptions and the
// event history go with it (ON DELETE CASCADE). An alert becomes a
// one-shot event: once every device has acknowledged, it cannot be
// triggered again and the admin token becomes dead.
// (b) retire only the DELIVERY record (and/or the event). The alert survives,
// stays armed, and can be triggered again later; only the ledger bookkeeping
// about this alarm is completed.
//
// Implementing either now would delete or preserve data on a reading nobody
// confirmed, so this function reports that the decision is pending. When the
// answer arrives it gets implemented in exactly one place and called from the
// paths that observe the last outstanding delivery (the alarmStatus check-in and
// the WebSocket ack), where "every client has been told" is decided atomically
// from the ledger rather than from a client's claim.
func completeAlertDelivery(ctx context.Context, alertID uuid.UUID) error {
_ = ctx
_ = alertID
return errors.New("completeAlertDelivery: pending user decision between (a) deleting the alert as a one-shot and (b) retiring only the delivery record")
}
+95
View File
@@ -0,0 +1,95 @@
// 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"
"net/http/httptest"
"testing"
"time"
"git.dcntrlized.org/Dcntrlized/Alarm/db"
"git.dcntrlized.org/Dcntrlized/Alarm/internal/apierr"
"github.com/google/uuid"
)
// TestWriteOfflineAlarmShape pins the 409 check-in contract: the status, the
// code, and that the body carries a real alarm frame with the SAME event id the
// device missed. This is the one response an integrator must not treat as an
// error, so its shape is not allowed to drift silently.
func TestWriteOfflineAlarmShape(t *testing.T) {
alertID, eventID := uuid.New(), uuid.New()
override := "Leave the building now"
alert := &db.Alert{ID: alertID, Message: &override}
at := time.Date(2026, 10, 18, 17, 5, 0, 123000000, time.UTC)
pending := &db.PendingDelivery{EventID: eventID, Action: "trigger", Message: &override, ServerTime: at}
rec := httptest.NewRecorder()
writeOfflineAlarm(rec, alert, pending)
if rec.Code != http.StatusConflict {
t.Fatalf("status = %d, want 409", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
t.Fatalf("Content-Type = %q, want application/json", ct)
}
var body struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
Alarm struct {
Type string `json:"type"`
AlertID string `json:"alertId"`
EventID string `json:"eventId"`
Action string `json:"action"`
Message string `json:"message"`
ServerTime string `json:"serverTime"`
} `json:"alarm"`
}
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatalf("decode body: %v", err)
}
if body.Error.Code != apierr.AlarmTriggeredOffline {
t.Fatalf("error.code = %q, want %q", body.Error.Code, apierr.AlarmTriggeredOffline)
}
if body.Error.Message == "" {
t.Fatal("error.message must not be empty")
}
if body.Alarm.Type != "alarm" || body.Alarm.Action != "trigger" {
t.Fatalf("alarm frame = %+v, want type=alarm action=trigger", body.Alarm)
}
if body.Alarm.EventID != eventID.String() || body.Alarm.AlertID != alertID.String() {
t.Fatalf("alarm ids = %q/%q, want %q/%q", body.Alarm.AlertID, body.Alarm.EventID, alertID, eventID)
}
if body.Alarm.Message != override {
t.Fatalf("alarm.message = %q, want %q", body.Alarm.Message, override)
}
if body.Alarm.ServerTime != "2026-10-18T17:05:00.123Z" {
t.Fatalf("alarm.serverTime = %q", body.Alarm.ServerTime)
}
}
// TestCompleteAlertDeliveryIsUnwired guards the deliberate hole: the function
// must keep saying "decision pending" rather than quietly deleting or preserving
// data on one reading of the user's sentence.
func TestCompleteAlertDeliveryIsUnwired(t *testing.T) {
if err := completeAlertDelivery(nil, uuid.New()); err == nil {
t.Fatal("completeAlertDelivery must report the pending decision, not succeed")
}
}
+102
View File
@@ -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[:])
}
+17 -23
View File
@@ -27,11 +27,11 @@ import (
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
"os"
"strings" "strings"
"time" "time"
"git.dcntrlized.org/Dcntrlized/Alarm/db" "git.dcntrlized.org/Dcntrlized/Alarm/db"
"git.dcntrlized.org/Dcntrlized/Alarm/internal/alertstate"
"git.dcntrlized.org/Dcntrlized/Alarm/internal/apierr" "git.dcntrlized.org/Dcntrlized/Alarm/internal/apierr"
"git.dcntrlized.org/dcntrlized/dcntrlized-auth/authclient" "git.dcntrlized.org/dcntrlized/dcntrlized-auth/authclient"
"github.com/google/uuid" "github.com/google/uuid"
@@ -46,6 +46,7 @@ const (
CodeAlertAlreadyJoined = apierr.AlertAlreadyJoined CodeAlertAlreadyJoined = apierr.AlertAlreadyJoined
CodeAlertNotSubscribed = apierr.AlertNotSubscribed CodeAlertNotSubscribed = apierr.AlertNotSubscribed
CodeAlertState = apierr.AlertState CodeAlertState = apierr.AlertState
CodeAlarmTriggeredOffline = apierr.AlarmTriggeredOffline
CodeValidation = apierr.Validation CodeValidation = apierr.Validation
CodeUnauthorized = apierr.Unauthorized CodeUnauthorized = apierr.Unauthorized
CodeInternal = apierr.Internal CodeInternal = apierr.Internal
@@ -60,22 +61,11 @@ const (
maxPlatformLen = 32 maxPlatformLen = 32
) )
// errorEnvelope is the platform error body. // writeError emits {"error":{"code","message"}}. It delegates to apierr.Write,
type errorEnvelope struct { // which guarantees both fields are non-empty: this package can pass a literal
Error struct { // message, but the invariant is enforced centrally (see internal/apierr).
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
// writeError emits {"error":{"code","message"}}.
func writeError(w http.ResponseWriter, status int, code, message string) { func writeError(w http.ResponseWriter, status int, code, message string) {
var body errorEnvelope apierr.Write(w, status, code, message)
body.Error.Code = code
body.Error.Message = message
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
} }
// writeJSON emits a success body. // writeJSON emits a success body.
@@ -119,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
@@ -180,13 +181,6 @@ func validateMessage(raw string) (string, error) {
return raw, nil return raw, nil
} }
func validateAutoStop(seconds int) error {
if seconds < 0 || seconds > alertstate.MaxAutoStopSeconds {
return fmt.Errorf("autoStopSeconds must be between 0 and %d", alertstate.MaxAutoStopSeconds)
}
return nil
}
func validateDeviceID(raw string) (string, error) { func validateDeviceID(raw string) (string, error) {
id := strings.TrimSpace(raw) id := strings.TrimSpace(raw)
if id == "" { if id == "" {
+48 -3
View File
@@ -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,7 +202,13 @@ 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 !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 { if err != nil {
dbError(w, action, err) dbError(w, action, err)
return return
@@ -211,6 +217,7 @@ func AlarmStatus(w http.ResponseWriter, r *http.Request, payload any) {
writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "not subscribed to this alert") writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "not subscribed to this alert")
return 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
// with the frame it missed instead of a quiet 200 — and clear NOTHING. The // with the frame it missed instead of a quiet 200 — and clear NOTHING. The
@@ -219,14 +226,31 @@ func AlarmStatus(w http.ResponseWriter, r *http.Request, payload any) {
// 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 := "" deviceHash := ""
if !c.IsDevice {
if raw := strings.TrimSpace(r.URL.Query().Get("deviceId")); raw != "" { if raw := strings.TrimSpace(r.URL.Query().Get("deviceId")); raw != "" {
deviceHash = deviceid.Hash(raw) deviceHash = deviceid.Hash(raw)
} }
pending, err := db.PendingTrigger(r.Context(), alertID, p.ID, deviceHash) }
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) { 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 {
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)
return return
@@ -239,6 +263,27 @@ func AlarmStatus(w http.ResponseWriter, r *http.Request, payload any) {
// not live even if the stored status says "triggered". // not live even if the stored status says "triggered".
active = false active = false
} }
// "Active" is a statement about THIS caller, not about the alert's commanded
// status. The alert stays triggered until the owner disarms it or every
// subscriber acknowledges, so answering a device that already dismissed the
// alarm with active:true makes a correct client re-raise the takeover on
// every poll — the alarm that will not stay dismissed. A subscriber is
// active only while it still owes the newest trigger; the owner (who has no
// delivery row) keeps seeing their own alert as live.
callerIsOwner := !c.IsDevice && res.Alert.AdminUserID == c.UserID
if active && !callerIsOwner {
var owed bool
if c.IsDevice {
owed, err = db.HasPendingDelivery(r.Context(), alertID, c.Sub.ID)
} else {
owed, err = db.HasPendingDeliveryForUser(r.Context(), alertID, c.UserID, deviceHash)
}
if err != nil {
dbError(w, action, err)
return
}
active = owed
}
view := statusView{ view := statusView{
Active: active, Active: active,
+9 -2
View File
@@ -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,7 +55,13 @@ 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 !c.subscribedTo(alertID) {
writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "not subscribed to this alert")
return
}
} else {
allowed, err := isAdminOrSubscriber(r, alert, c.UserID)
if err != nil { if err != nil {
dbError(w, action, err) dbError(w, action, err)
return return
@@ -64,6 +70,7 @@ func QRImage(w http.ResponseWriter, r *http.Request, payload any) {
writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "not subscribed to this alert") writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "not subscribed to this alert")
return return
} }
}
scale, err := intQuery(r, "scale", defaultQRScale, 1, qr.MaxScale) scale, err := intQuery(r, "scale", defaultQRScale, 1, qr.MaxScale)
if err != nil { if err != nil {
+100 -22
View File
@@ -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(),
+108
View File
@@ -0,0 +1,108 @@
// 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 db
import (
"context"
"crypto/sha256"
"encoding/hex"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgconn"
)
// The one durable, anonymised lifecycle log: alarm.event_log. It records only
// important events — never stdout (that stays on stdout), never request
// metadata, never a network identifier.
//
// Event values are stable slugs; `reason` is the minimal discriminator an ending
// needs (`delivered`, `expired`, `disarmed`, `deleted`). Identities are SHA-256
// hex of the UUID string: UUIDs carry ~122 random bits, so a plain digest is
// genuinely one-way here.
const (
EventCreated = "created"
EventJoined = "joined"
EventTriggered = "triggered"
EventDelivered = "delivered"
EventDestroyed = "destroyed"
EventLoginFail = "login_failed"
EventJoinReject = "join_rejected"
)
// Destruction reasons, set on `destroyed` rows only.
const (
DestroyReasonDelivered = "delivered" // the delivery ledger drained
DestroyReasonExpired = "expired" // the owner's persistence cap fired
DestroyReasonDeleted = "deleted" // the owner explicitly deleted it
DestroyReasonDisarmed = "disarmed" // the owner disarmed it
)
// hashUUID returns SHA-256 hex of the UUID's canonical string.
func hashUUID(id uuid.UUID) string {
sum := sha256.Sum256([]byte(id.String()))
return hex.EncodeToString(sum[:])
}
// HashUUID exposes the digest so tests can query rows without the raw id.
func HashUUID(id uuid.UUID) string { return hashUUID(id) }
// execer is the subset of pgxpool.Pool / pgx.Tx the event writer needs, so a
// lifecycle row can be written inside the transaction that caused it.
type execer interface {
Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
}
// recordEvent appends one anonymised lifecycle row. It never receives a raw id:
// callers pass UUIDs and this is the only place identities are hashed.
func recordEvent(ctx context.Context, ex execer, alertID uuid.UUID, userID *uuid.UUID, event, reason string) error {
var userHash *string
if userID != nil {
h := hashUUID(*userID)
userHash = &h
}
var reasonPtr *string
if reason != "" {
reasonPtr = &reason
}
_, err := ex.Exec(ctx,
`INSERT INTO alarm.event_log (event, alert_hash, user_hash, reason)
VALUES ($1, $2, $3, $4)`,
event, hashUUID(alertID), userHash, reasonPtr)
return err
}
// RecordDelivered appends the `delivered` row for a device that just acknowledged
// an alarm. Callers fire it only when an ack actually cleared a ledger row, so a
// repeated ack does not inflate the count.
func RecordDelivered(ctx context.Context, alertID, userID uuid.UUID) error {
return recordEvent(ctx, Pool(), alertID, &userID, EventDelivered, "")
}
// EventLogRetention is the age-based retention for the event log.
const EventLogRetention = 7 * 24 * time.Hour
// PruneEventLog enforces the age-based retention: the growth table is swept with
// expiry, and seven days is enough to answer "what happened last night" without
// keeping a permanent behavioural record. Returns rows deleted.
func PruneEventLog(ctx context.Context, maxAge time.Duration) (int64, error) {
tag, err := Pool().Exec(ctx,
`DELETE FROM alarm.event_log WHERE occurred_at < now() - $1::interval`, maxAge.String())
if err != nil {
return 0, err
}
return tag.RowsAffected(), nil
}
+6
View File
@@ -112,6 +112,12 @@ func Close() {
if instance != nil && instance.Pool != nil { if instance != nil && instance.Pool != nil {
instance.Pool.Close() instance.Pool.Close()
} }
// Reset the singleton so a later NewDB()/Pool() opens a fresh pool instead
// of handing out the closed one. The probes in this package each open and
// close their own pool, and a sequential run of two probes must not inherit
// the previous one's closed pool.
instance = nil
once = sync.Once{}
} }
// redact strips the password from a DSN before it can reach a log line: // redact strips the password from a DSN before it can reach a log line:
+296
View File
@@ -0,0 +1,296 @@
// 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 db
import (
"context"
"errors"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// PendingDelivery is an unacked delivery together with the event it points at.
// It is the payload of the "you missed an alarm" check-in.
type PendingDelivery struct {
EventID uuid.UUID
Action string
Message *string
ServerTime time.Time
}
// PendingTrigger is the READ-ONLY offline check-in: it reports the newest
// trigger this device still owes, or ErrNotFound when it owes nothing. It marks
// NO row — clearing is ack-driven (see AckAlarmDelivery / AckDelivery), because a
// check-in answer can be lost in transit and must not forfeit the alarm.
//
// deviceID selects the subscription the same way the socket does: the exact
// stored digest when given, otherwise the caller's oldest subscription on the
// alert. deviceID is expected to be ALREADY HASHED (actions hash the raw query
// value before calling); passing a raw id would simply match nothing.
func PendingTrigger(ctx context.Context, alertID, userID uuid.UUID, deviceID string) (*PendingDelivery, error) {
const q = `
WITH target AS (
SELECT s.id
FROM alarm.subscriptions s
WHERE s.alert_id = $1
AND s.subscriber_user_id = $2
AND ($3 = '' OR s.device_id = $3)
ORDER BY s.created, s.id
LIMIT 1
)
SELECT e.id, e.kind::text, e.message, e.created_at
FROM alarm.alarm_deliveries d
JOIN alarm.alarm_events e ON e.id = d.event_id
WHERE d.alert_id = $1
AND d.subscription_id = (SELECT id FROM target)
AND d.acked_at IS NULL
AND d.delivered_at IS NULL
ORDER BY e.created_at DESC, e.id DESC
LIMIT 1`
var out PendingDelivery
err := Pool().QueryRow(ctx, q, alertID, userID, deviceID).Scan(
&out.EventID, &out.Action, &out.Message, &out.ServerTime)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
return &out, nil
}
// PendingReplay returns the trigger frames a subscription still owes, oldest
// first — the WebSocket reconnect path. It marks nothing: a replay is not a
// delivery, and the debt only clears when the client acks (or checks in), so a
// frame the client never processed stays owed.
//
// A trigger that has since been disarmed is not replayed: ringing a stale alarm
// on reconnect would be worse than staying silent, and the check-in clears that
// debt instead (see ClaimPendingDelivery). The NOT EXISTS keeps this one indexed
// anti-join rather than a scan of the event history.
//
// The LIMIT bounds the work a pathological ledger can put on a handshake; more
// than a handful of owed events for one device means something is very wrong.
func PendingReplay(ctx context.Context, alertID, subscriptionID uuid.UUID) ([]PendingDelivery, error) {
const q = `
SELECT e.id, e.kind::text, e.message, e.created_at
FROM alarm.alarm_deliveries d
JOIN alarm.alarm_events e ON e.id = d.event_id
WHERE d.alert_id = $1
AND d.subscription_id = $2
AND d.acked_at IS NULL
AND d.delivered_at IS NULL
AND NOT EXISTS (
SELECT 1 FROM alarm.alarm_events x
WHERE x.alert_id = d.alert_id
AND x.kind = 'disarm'
AND x.created_at > e.created_at
)
ORDER BY e.created_at, e.id
LIMIT 20`
rows, err := Pool().Query(ctx, q, alertID, subscriptionID)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]PendingDelivery, 0, 2)
for rows.Next() {
var d PendingDelivery
if err := rows.Scan(&d.EventID, &d.Action, &d.Message, &d.ServerTime); err != nil {
return nil, err
}
out = append(out, d)
}
return out, rows.Err()
}
// AckDelivery marks one delivery acknowledged (acked_at = now()) and reports how
// many rows it cleared. It is the WebSocket parity path: a client that received
// and acked the live frame never also gets the offline check-in. Idempotent by
// construction — a repeated ack (or an ack for an event the device never owed)
// updates nothing and is not an error; the zero count is what keeps analytics
// honest and is why callers only fire the delivered row when n > 0.
func AckDelivery(ctx context.Context, alertID, subscriptionID, eventID uuid.UUID) (int64, error) {
tag, err := Pool().Exec(ctx,
`UPDATE alarm.alarm_deliveries
SET acked_at = now(), delivered_at = coalesce(delivered_at, now())
WHERE alert_id = $1 AND subscription_id = $2 AND event_id = $3
AND acked_at IS NULL`,
alertID, subscriptionID, eventID)
if err != nil {
return 0, err
}
return tag.RowsAffected(), nil
}
// AckAlarmDelivery clears the caller's subscription rows for one event and
// reports how many were cleared — the explicit `ackAlarm` action, sent when the
// person holding the phone dismisses a ringing alarm.
//
// deviceID is the ALREADY HASHED digest of the raw client value; the actor is
// addressed through (alert, user, device) exactly like the socket, so a caller can
// only ever clear its own device's debt. Idempotent: a repeat ack matches no row
// and returns 0, never an error.
func AckAlarmDelivery(ctx context.Context, alertID, userID uuid.UUID, deviceID string, eventID uuid.UUID) (int64, error) {
const q = `
UPDATE alarm.alarm_deliveries d
SET acked_at = now(), delivered_at = coalesce(d.delivered_at, now())
WHERE d.alert_id = $1
AND d.event_id = $2
AND d.acked_at IS NULL
AND d.subscription_id IN (
SELECT s.id FROM alarm.subscriptions s
WHERE s.alert_id = $1
AND s.subscriber_user_id = $3
AND s.device_id = $4
)`
tag, err := Pool().Exec(ctx, q, alertID, eventID, userID, deviceID)
if err != nil {
return 0, err
}
return tag.RowsAffected(), nil
}
// HasPendingDelivery reports whether a subscription still owes an alarm — the
// question `alarmStatus` has to ask before it calls an alert "active".
//
// An alert stays commanded "triggered" until the owner disarms it or every
// subscriber acknowledges, so the commanded status alone is not the caller's
// answer: a device that already dismissed the alarm must be told it owes
// nothing, or it re-raises the same takeover forever.
func HasPendingDelivery(ctx context.Context, alertID, subscriptionID uuid.UUID) (bool, error) {
var owed bool
err := Pool().QueryRow(ctx,
`SELECT EXISTS (
SELECT 1 FROM alarm.alarm_deliveries
WHERE alert_id = $1 AND subscription_id = $2
AND acked_at IS NULL AND delivered_at IS NULL)`,
alertID, subscriptionID).Scan(&owed)
if err != nil {
return false, err
}
return owed, nil
}
// HasPendingDeliveryForUser is the account-path equivalent: the caller's debt is
// addressed by (alert, user, device) exactly like the socket and the ack, with
// an empty device hash meaning "any of this user's subscriptions on the alert".
func HasPendingDeliveryForUser(ctx context.Context, alertID, userID uuid.UUID, deviceHash string) (bool, error) {
var owed bool
err := Pool().QueryRow(ctx,
`SELECT EXISTS (
SELECT 1
FROM alarm.alarm_deliveries d
JOIN alarm.subscriptions s ON s.id = d.subscription_id
WHERE d.alert_id = $1
AND d.acked_at IS NULL
AND d.delivered_at IS NULL
AND s.subscriber_user_id = $2
AND ($3 = '' OR s.device_id = $3))`,
alertID, userID, deviceHash).Scan(&owed)
if err != nil {
return false, err
}
return owed, nil
}
// AckAllDeliveries clears every alarm this subscription still owes, and reports
// how many rows it retired. It is the dismissal that does not know an event id —
// a native takeover launch carries only the alert, and re-deriving "which
// trigger was I ringing for" from the phone is guesswork. Acknowledging means
// "this device has heard the alarm", so clearing the whole outstanding set is
// the honest reading and the one that cannot leave a stale debt replaying.
func AckAllDeliveries(ctx context.Context, alertID, subscriptionID uuid.UUID) (int64, error) {
tag, err := Pool().Exec(ctx,
`UPDATE alarm.alarm_deliveries
SET acked_at = now(), delivered_at = coalesce(delivered_at, now())
WHERE alert_id = $1 AND subscription_id = $2 AND acked_at IS NULL`,
alertID, subscriptionID)
if err != nil {
return 0, err
}
return tag.RowsAffected(), nil
}
// AckAllForSubscriber is AckAllDeliveries for the account path, addressed by
// (alert, user, device) so a caller can only ever clear its own debt.
func AckAllForSubscriber(ctx context.Context, alertID, userID uuid.UUID, deviceHash string) (int64, error) {
tag, err := Pool().Exec(ctx,
`UPDATE alarm.alarm_deliveries d
SET acked_at = now(), delivered_at = coalesce(d.delivered_at, now())
WHERE d.alert_id = $1
AND d.acked_at IS NULL
AND d.subscription_id IN (
SELECT s.id FROM alarm.subscriptions s
WHERE s.alert_id = $1
AND s.subscriber_user_id = $2
AND ($3 = '' OR s.device_id = $3))`,
alertID, userID, deviceHash)
if err != nil {
return 0, err
}
return tag.RowsAffected(), nil
}
// MarkDelivered records that the device has the trigger, without treating that
// as a dismissal.
//
// Clients ack a frame the moment they receive it so the service stops offering
// the same alarm on every poll. That is a receipt, not a decision by the person
// holding the phone: the reveal screen may still be behind a locked screen, and
// the alarm is still ringing. Recording it separately is what stops the drain
// rule from destroying an alert while it is still sounding.
//
// Idempotent and best-effort by construction: a repeat, a malformed event id or
// an admin connection match nothing.
func MarkDelivered(ctx context.Context, alertID, subscriptionID, eventID uuid.UUID) (int64, error) {
tag, err := Pool().Exec(ctx,
`UPDATE alarm.alarm_deliveries
SET delivered_at = now()
WHERE alert_id = $1 AND subscription_id = $2 AND event_id = $3
AND delivered_at IS NULL AND acked_at IS NULL`,
alertID, subscriptionID, eventID)
if err != nil {
return 0, err
}
return tag.RowsAffected(), nil
}
// MarkAllDeliveredForUser is MarkDelivered for the account path: everything this
// caller's device still has not received is now received.
func MarkAllDeliveredForUser(ctx context.Context, alertID, userID uuid.UUID, deviceHash string) (int64, error) {
tag, err := Pool().Exec(ctx,
`UPDATE alarm.alarm_deliveries d
SET delivered_at = now()
WHERE d.alert_id = $1
AND d.acked_at IS NULL
AND d.delivered_at IS NULL
AND d.subscription_id IN (
SELECT s.id FROM alarm.subscriptions s
WHERE s.alert_id = $1
AND s.subscriber_user_id = $2
AND ($3 = '' OR s.device_id = $3))`,
alertID, userID, deviceHash)
if err != nil {
return 0, err
}
return tag.RowsAffected(), nil
}
+293
View File
@@ -0,0 +1,293 @@
// 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 db
// The lifecycle probe. It is skipped unless DATABASE_URL points at a database
// with migrations 001–008 applied; the default suite stays DB-free. It covers the
// four ways an alert can end plus the ledger and log-table mechanics that only
// SQL can prove:
//
// - drain-to-empty (every subscriber acked) destroys the alert and writes
// `destroyed/delivered` in one transaction;
// - a trigger with zero subscribers does NOT destroy (guard 1);
// - expiry destroys with unacked rows and the alert reads as not-found;
// - disarm of a triggered alert destroys with reason `disarmed`;
// - acks are idempotent; the second ack clears nothing;
// - event-log retention pruning works.
//
// Run it with:
//
// DATABASE_URL='postgres://postgres:[email protected]:55432/dcntrlized?sslmode=disable' \
// go test -run TestLedgerProbe -count=1 -v ./db/
//
// It uses its own freshly-created alerts and deletes them at the end.
import (
"context"
"errors"
"os"
"strings"
"testing"
"time"
"git.dcntrlized.org/Dcntrlized/Alarm/internal/alertstate"
"github.com/google/uuid"
)
func TestLedgerProbe(t *testing.T) {
dsn := os.Getenv("DATABASE_URL")
if dsn == "" {
t.Skip("DATABASE_URL not set; lifecycle probe needs a migrated database")
}
ctx := context.Background()
_ = dsn
NewDB()
t.Cleanup(Close)
admin := uuid.New()
sub1 := uuid.New()
sub2 := uuid.New()
sub3 := uuid.New()
newAlert := func(name string, ttl time.Duration) *Alert {
msg := "probe"
a, err := CreateAlert(ctx, &Alert{
AdminUserID: admin,
Name: name,
Message: &msg,
AdminTokenHash: []byte("probe"),
SubscribeToken: "probe-" + uuid.NewString(),
ExpiresAt: time.Now().UTC().Add(ttl),
})
if err != nil {
t.Fatalf("CreateAlert: %v", err)
}
return a
}
// Each subscription needs its own device id: migration 010 enforces
// (alert_id, device_id) uniqueness so one device has at most one
// subscription per alert.
join := func(alertID, user uuid.UUID) *Subscription {
s, err := CreateSubscription(ctx, &Subscription{
AlertID: alertID, SubscriberUserID: user,
DeviceID: "sha256:" + strings.ReplaceAll(user.String(), "-", ""),
})
if err != nil {
t.Fatalf("CreateSubscription: %v", err)
}
return s
}
countAnalytics := func(alertID uuid.UUID, event, reason string) int {
var n int
err := Pool().QueryRow(ctx,
`SELECT count(*) FROM alarm.event_log WHERE alert_hash = $1 AND event = $2 AND coalesce(reason,'') = $3`,
HashUUID(alertID), event, reason).Scan(&n)
if err != nil {
t.Fatalf("analytics count: %v", err)
}
return n
}
// ---- drain-to-empty destroys the alert with reason "delivered" ----
drain := newAlert("probe drain", time.Hour)
join(drain.ID, sub1)
join(drain.ID, sub2)
join(drain.ID, sub3) // third enabled subscriber: acking a different device's row must not count
disabledUser := uuid.New()
disabled, err := CreateSubscription(ctx, &Subscription{
AlertID: drain.ID, SubscriberUserID: disabledUser,
DeviceID: "sha256:" + strings.ReplaceAll(disabledUser.String(), "-", ""),
})
if err != nil {
t.Fatalf("disabled subscription: %v", err)
}
if _, err := Pool().Exec(ctx, `UPDATE alarm.subscriptions SET enabled = false WHERE id = $1`, disabled.ID); err != nil {
t.Fatalf("disable sub: %v", err)
}
_, ev, err := TriggerAlert(ctx, drain.ID, admin, nil)
if err != nil {
t.Fatalf("TriggerAlert: %v", err)
}
var rows, enabled int
if err := Pool().QueryRow(ctx,
`SELECT count(*) FROM alarm.alarm_deliveries WHERE alert_id = $1`, drain.ID).Scan(&rows); err != nil {
t.Fatalf("ledger rows: %v", err)
}
if err := Pool().QueryRow(ctx,
`SELECT count(*) FROM alarm.subscriptions WHERE alert_id = $1 AND enabled`, drain.ID).Scan(&enabled); err != nil {
t.Fatalf("enabled count: %v", err)
}
if rows != 3 || enabled != 3 {
t.Fatalf("trigger produced %d ledger rows for %d enabled subscriptions, want 3/3", rows, enabled)
}
if got := countAnalytics(drain.ID, EventTriggered, ""); got != 1 {
t.Fatalf("triggered analytics rows = %d, want 1", got)
}
pending, err := PendingTrigger(ctx, drain.ID, sub1, "")
if err != nil || pending.EventID != ev.ID {
t.Fatalf("PendingTrigger = %+v, %v; want event %s", pending, err, ev.ID)
}
// Ack all three, idempotently. Only the last one completes the drain.
for i, user := range []uuid.UUID{sub1, sub2, sub3} {
sub, err := deviceDigest(ctx, drain.ID, user)
if err != nil {
t.Fatalf("digest %d: %v", i, err)
}
n, err := AckAlarmDelivery(ctx, drain.ID, user, sub, ev.ID)
if err != nil {
t.Fatalf("AckAlarmDelivery %d: %v", i, err)
}
if n != 1 {
t.Fatalf("ack %d cleared %d rows, want 1", i, n)
}
if again, err := AckAlarmDelivery(ctx, drain.ID, user, sub, ev.ID); err != nil || again != 0 {
t.Fatalf("repeat ack %d = %d, %v; want 0, nil", i, again, err)
}
}
destroyed, err := CompleteAlertDeliveries(ctx, drain.ID)
if err != nil {
t.Fatalf("CompleteAlertDeliveries: %v", err)
}
if !destroyed {
t.Fatal("drained alert was not destroyed")
}
if _, err := GetAlert(ctx, drain.ID); !errors.Is(err, ErrNotFound) {
t.Fatalf("drained alert read = %v, want ErrNotFound", err)
}
if got := countAnalytics(drain.ID, EventDestroyed, DestroyReasonDelivered); got != 1 {
t.Fatalf("destroyed/delivered analytics rows = %d, want 1", got)
}
// ---- guard 1: a zero-subscriber trigger must NOT destroy ----
empty := newAlert("probe empty", time.Hour)
if _, _, err := TriggerAlert(ctx, empty.ID, admin, nil); err != nil {
t.Fatalf("TriggerAlert(empty): %v", err)
}
ok, err := CompleteAlertDeliveries(ctx, empty.ID)
if err != nil {
t.Fatalf("CompleteAlertDeliveries(empty): %v", err)
}
if ok {
t.Fatal("zero-subscriber trigger destroyed the owner's alert")
}
if _, err := GetAlert(ctx, empty.ID); err != nil {
t.Fatalf("zero-subscriber alert should still exist: %v", err)
}
// ---- expiry destroys with unacked rows, and reads 404 before the sweeper ----
expiring := newAlert("probe expire", time.Hour)
join(expiring.ID, sub1)
if _, _, err := TriggerAlert(ctx, expiring.ID, admin, nil); err != nil {
t.Fatalf("TriggerAlert(expiring): %v", err)
}
if _, err := Pool().Exec(ctx, `UPDATE alarm.alerts SET expires_at = now() - interval '1 second' WHERE id = $1`, expiring.ID); err != nil {
t.Fatalf("force expiry: %v", err)
}
if _, err := GetAlert(ctx, expiring.ID); !errors.Is(err, ErrNotFound) {
t.Fatalf("expired alert read = %v, want ErrNotFound", err)
}
n, err := ExpireAlerts(ctx, 100)
if err != nil || n < 1 {
t.Fatalf("ExpireAlerts = %d, %v; want >= 1", n, err)
}
if got := countAnalytics(expiring.ID, EventDestroyed, DestroyReasonExpired); got != 1 {
t.Fatalf("destroyed/expired analytics rows = %d, want 1", got)
}
var leftovers int
if err := Pool().QueryRow(ctx,
`SELECT count(*) FROM alarm.alarm_deliveries WHERE alert_id = $1`, expiring.ID).Scan(&leftovers); err != nil {
t.Fatalf("leftover rows: %v", err)
}
if leftovers != 0 {
t.Fatalf("expiry left %d delivery rows, want 0 (cascade)", leftovers)
}
// ---- disarm of a triggered alert ends it ----
disarmed := newAlert("probe disarm", time.Hour)
join(disarmed.ID, sub1)
if _, _, err := TriggerAlert(ctx, disarmed.ID, admin, nil); err != nil {
t.Fatalf("TriggerAlert(disarm): %v", err)
}
if _, _, err := DisarmAlert(ctx, disarmed.ID, admin, nil); err != nil {
t.Fatalf("DisarmAlert: %v", err)
}
if _, err := GetAlert(ctx, disarmed.ID); !errors.Is(err, ErrNotFound) {
t.Fatalf("disarmed alert read = %v, want ErrNotFound", err)
}
if got := countAnalytics(disarmed.ID, EventDestroyed, DestroyReasonDisarmed); got != 1 {
t.Fatalf("destroyed/disarmed analytics rows = %d, want 1", got)
}
// ---- deletion wins over debt: cascade removes the ledger, no replay/409 ----
deleted := newAlert("probe delete", time.Hour)
join(deleted.ID, sub1)
if _, _, err := TriggerAlert(ctx, deleted.ID, admin, nil); err != nil {
t.Fatalf("TriggerAlert(deleted): %v", err)
}
if err := DeleteAlert(ctx, deleted.ID); err != nil {
t.Fatalf("DeleteAlert: %v", err)
}
if _, err := PendingTrigger(ctx, deleted.ID, sub1, ""); !errors.Is(err, ErrNotFound) {
t.Fatalf("deleted alert still owes a delivery: %v", err)
}
if _, err := GetAlert(ctx, deleted.ID); !errors.Is(err, ErrNotFound) {
t.Fatalf("deleted alert read = %v, want ErrNotFound", err)
}
if got := countAnalytics(deleted.ID, EventDestroyed, DestroyReasonDeleted); got != 1 {
t.Fatalf("destroyed/deleted event rows = %d, want 1", got)
}
// ---- disarm of an ARMED alert also ends it (delete-without-trigger) ----
armed := newAlert("probe armed", time.Hour)
if _, _, err := DisarmAlert(ctx, armed.ID, admin, nil); err != nil {
t.Fatalf("DisarmAlert(armed): %v", err)
}
if _, err := GetAlert(ctx, armed.ID); !errors.Is(err, ErrNotFound) {
t.Fatalf("armed disarm left the alert alive: %v", err)
}
if got := countAnalytics(armed.ID, EventDestroyed, DestroyReasonDisarmed); got != 1 {
t.Fatalf("armed disarm event rows = %d, want 1", got)
}
// ---- event-log retention: age-based delete ----
if _, err := Pool().Exec(ctx,
`INSERT INTO alarm.event_log (occurred_at, event, alert_hash) VALUES (now() - interval '30 days', 'created', $1)`,
HashUUID(uuid.New())); err != nil {
t.Fatalf("insert old event: %v", err)
}
if n, err := PruneEventLog(ctx, EventLogRetention); err != nil || n < 1 {
t.Fatalf("PruneEventLog = %d, %v; want >= 1", n, err)
}
// Clean any leftover probe alerts (drain/empty) so repeat runs are clean.
_, _ = Pool().Exec(ctx, `DELETE FROM alarm.alerts WHERE name LIKE 'probe %'`)
t.Log("lifecycle probe ok: 3/3 ledger rows, idempotent acks, drain/zero-sub/expiry/disarm/deletion endings, event-log retention")
}
// deviceDigest reads back the stored (already hashed) device id of a subscription.
func deviceDigest(ctx context.Context, alertID, userID uuid.UUID) (string, error) {
var d string
err := Pool().QueryRow(ctx,
`SELECT device_id FROM alarm.subscriptions WHERE alert_id = $1 AND subscriber_user_id = $2
ORDER BY created, id LIMIT 1`, alertID, userID).Scan(&d)
return d, err
}
var _ = alertstate.KindTrigger
+141
View File
@@ -0,0 +1,141 @@
// 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 db
// The device-ledger probe: what an anonymous subscriber's dismissal does to the
// delivery ledger. Skipped unless DATABASE_URL points at a migrated database
// (same gate as TestLedgerProbe):
//
// DATABASE_URL='postgres://postgres:…@127.0.0.1:55432/dcntrlized?sslmode=disable' \
// go test -run TestDeviceLedgerProbe -count=1 -v ./db/
//
// It proves the three queries `alarmStatus` and `ackAlarm` now depend on:
// - a device subscription with no user id still owes (and clears) alarms;
// - an "ack all" dismissal retires the whole outstanding set, which is how a
// native takeover launch (no event id) stops a replay loop;
// - "does this caller owe the alarm?" answers per subscriber, so one device
// dismissing does not silence another device's owed alarm.
import (
"context"
"os"
"strings"
"testing"
"time"
"github.com/google/uuid"
)
func TestDeviceLedgerProbe(t *testing.T) {
dsn := os.Getenv("DATABASE_URL")
if dsn == "" {
t.Skip("DATABASE_URL not set; device ledger probe needs a migrated database")
}
ctx := context.Background()
NewDB()
t.Cleanup(Close)
admin := uuid.New()
account := uuid.New()
msg := "probe"
a, err := CreateAlert(ctx, &Alert{
AdminUserID: admin,
Name: "probe device ledger",
Message: &msg,
AdminTokenHash: []byte("probe"),
SubscribeToken: "probe-" + uuid.NewString(),
ExpiresAt: time.Now().UTC().Add(time.Hour),
})
if err != nil {
t.Fatalf("CreateAlert: %v", err)
}
t.Cleanup(func() {
_, _ = Pool().Exec(context.Background(), `DELETE FROM alarm.alerts WHERE id = $1`, a.ID)
})
tokenHash := strings.Repeat("c", 64)
device, err := CreateSubscription(ctx, &Subscription{
AlertID: a.ID,
DeviceID: "sha256:" + strings.Repeat("d", 64),
DeviceTokenHash: &tokenHash,
})
if err != nil {
t.Fatalf("CreateSubscription (device): %v", err)
}
if _, err := CreateSubscription(ctx, &Subscription{
AlertID: a.ID, SubscriberUserID: account, DeviceID: "sha256:" + strings.Repeat("e", 64),
}); err != nil {
t.Fatalf("CreateSubscription (account): %v", err)
}
_, event, err := TriggerAlert(ctx, a.ID, admin, nil)
if err != nil {
t.Fatalf("TriggerAlert: %v", err)
}
if ok, err := HasPendingDelivery(ctx, a.ID, device.ID); err != nil || !ok {
t.Fatalf("device owes after trigger: %v %v", ok, err)
}
if ok, err := HasPendingDeliveryForUser(ctx, a.ID, account, ""); err != nil || !ok {
t.Fatalf("account owes after trigger: %v %v", ok, err)
}
// A receipt is not a dismissal. The clients ack a frame the moment they get
// it, and that used to be written to acked_at — which destroyed the alert
// while it was still ringing (measured on the emulator: 27 ms after the
// trigger). A receipt settles re-delivery only.
marked, err := MarkDelivered(ctx, a.ID, device.ID, event.ID)
if err != nil || marked != 1 {
t.Fatalf("MarkDelivered = %d, %v; want 1", marked, err)
}
if ok, err := HasPendingDelivery(ctx, a.ID, device.ID); err != nil || ok {
t.Fatalf("device still owes after a receipt: %v %v", ok, err)
}
if replay, err := PendingReplay(ctx, a.ID, device.ID); err != nil || len(replay) != 0 {
t.Fatalf("device replay after a receipt = %d rows, %v; want 0", len(replay), err)
}
if destroyed, err := CompleteAlertDeliveries(ctx, a.ID); err != nil || destroyed {
t.Fatalf("a receipt destroyed the alert: %v %v", destroyed, err)
}
// The dismissal is what the drain counts. A device that received and later
// dismissed must not end the alert while another subscriber has not.
if got, err := AckDelivery(ctx, a.ID, device.ID, event.ID); err != nil || got != 1 {
t.Fatalf("AckDelivery after a receipt = %d, %v; want 1 (the dismissal still has to be recorded)", got, err)
}
if destroyed, err := CompleteAlertDeliveries(ctx, a.ID); err != nil || destroyed {
t.Fatalf("one dismissal destroyed an alert another subscriber never dismissed: %v %v", destroyed, err)
}
if ok, err := HasPendingDeliveryForUser(ctx, a.ID, account, ""); err != nil || !ok {
t.Fatalf("account debt lost when the device dismissed: %v %v", ok, err)
}
if got, err := MarkAllDeliveredForUser(ctx, a.ID, account, ""); err != nil || got != 1 {
t.Fatalf("MarkAllDeliveredForUser = %d, %v; want 1", got, err)
}
if got, err := AckAllForSubscriber(ctx, a.ID, account, ""); err != nil || got != 1 {
t.Fatalf("AckAllForSubscriber = %d, %v; want 1", got, err)
}
if destroyed, err := CompleteAlertDeliveries(ctx, a.ID); err != nil || !destroyed {
t.Fatalf("the last dismissal did not destroy the alert: %v %v", destroyed, err)
}
// The alert, its subscriptions and the device's token row are all gone.
if ok, err := HasPendingDelivery(ctx, a.ID, device.ID); err != nil || ok {
t.Fatalf("device owes on a destroyed alert: %v %v", ok, err)
}
if _, err := GetSubscriptionByToken(context.Background(), tokenHash); err == nil {
t.Fatalf("device token still resolves after the alert was destroyed")
}
}
+7 -1
View File
@@ -30,6 +30,8 @@ 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
} }
+70 -11
View File
@@ -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 + `
@@ -113,12 +169,15 @@ func ListSubscriptions(ctx context.Context, alertID uuid.UUID) ([]Subscription,
out := make([]Subscription, 0, 4) out := make([]Subscription, 0, 4)
for rows.Next() { for rows.Next() {
var s Subscription // Same scan as every other read: subscriber_user_id is NULL for a device
if err := rows.Scan(&s.ID, &s.AlertID, &s.SubscriberUserID, &s.DeviceID, &s.DeviceLabel, // subscription (rev 10) and device_token_hash is part of the column set,
&s.Platform, &s.Enabled, &s.LastSeenAt, &s.Created); err != nil { // so a hand-written destination list here drifts and breaks the listing
// for exactly the rows anonymous access creates.
s, err := scanSubscription(rows)
if err != nil {
return nil, err return nil, err
} }
out = append(out, s) out = append(out, *s)
} }
return out, rows.Err() return out, rows.Err()
} }
+52 -21
View File
@@ -1,3 +1,18 @@
// 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 package actions
import ( import (
@@ -5,9 +20,35 @@ import (
"net/http" "net/http"
"git.dcntrlized.org/Dcntrlized/Alarm/actions" "git.dcntrlized.org/Dcntrlized/Alarm/actions"
"git.dcntrlized.org/Dcntrlized/Alarm/internal/apierr"
"git.dcntrlized.org/Dcntrlized/Alarm/types" "git.dcntrlized.org/Dcntrlized/Alarm/types"
) )
// This layer routes /action/{actionName} by HTTP method. It sits in front of the
// action handlers, so its errors are plain text (apierr.WriteText) rather than
// the JSON envelope the handlers emit; API.md documents that boundary. Both
// emitters guarantee a non-empty message.
const notImplementedMessage = "Action not implemented"
// notImplemented answers an action/verb pair the dispatcher does not route.
func notImplemented(w http.ResponseWriter) {
apierr.WriteText(w, http.StatusNotImplemented, notImplementedMessage)
}
// decodeEnvelope reads the {payload} wrapper. Unknown top-level fields are
// rejected (the inner payload is decoded leniently by the action itself).
func decodeEnvelope(w http.ResponseWriter, r *http.Request) (types.ActionRequest, bool) {
var req types.ActionRequest
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(&req); err != nil {
apierr.WriteText(w, http.StatusBadRequest, err.Error())
return req, false
}
return req, true
}
// ActionActionNameGet dispatches the GET /action/{actionName} request. // ActionActionNameGet dispatches the GET /action/{actionName} request.
// //
// GET carries no body: the read actions take their arguments from the query // GET carries no body: the read actions take their arguments from the query
@@ -30,7 +71,7 @@ func ActionActionNameGet(w http.ResponseWriter, r *http.Request, params map[stri
case "me": case "me":
actions.Me(w, r) actions.Me(w, r)
default: default:
http.Error(w, "Action not implemented", http.StatusNotImplemented) notImplemented(w)
return return
} }
} }
@@ -38,11 +79,8 @@ func ActionActionNameGet(w http.ResponseWriter, r *http.Request, params map[stri
// ActionActionNamePost dispatches the POST /action/{actionName} request. // ActionActionNamePost dispatches the POST /action/{actionName} request.
func ActionActionNamePost(w http.ResponseWriter, r *http.Request, params map[string]string) { func ActionActionNamePost(w http.ResponseWriter, r *http.Request, params map[string]string) {
action := params["actionName"] action := params["actionName"]
var req types.ActionRequest req, ok := decodeEnvelope(w, r)
dec := json.NewDecoder(r.Body) if !ok {
dec.DisallowUnknownFields()
if err := dec.Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return return
} }
@@ -64,12 +102,12 @@ func ActionActionNamePost(w http.ResponseWriter, r *http.Request, params map[str
actions.DisarmAlert(w, r, req.Payload) actions.DisarmAlert(w, r, req.Payload)
case "alarmStatus": case "alarmStatus":
actions.AlarmStatus(w, r, req.Payload) actions.AlarmStatus(w, r, req.Payload)
case "ackAlarm":
actions.AckAlarm(w, r, req.Payload)
case "deleteAlert": case "deleteAlert":
actions.DeleteAlert(w, r, req.Payload) actions.DeleteAlert(w, r, req.Payload)
case "qrImage": case "qrImage":
actions.QRImage(w, r, req.Payload) actions.QRImage(w, r, req.Payload)
case "rotateSubscribeToken":
actions.RotateSubscribeToken(w, r, req.Payload)
// Shared-auth proxies. // Shared-auth proxies.
case "login": case "login":
@@ -83,7 +121,7 @@ func ActionActionNamePost(w http.ResponseWriter, r *http.Request, params map[str
case "logout": case "logout":
actions.Logout(w, r, req.Payload) actions.Logout(w, r, req.Payload)
default: default:
http.Error(w, "Action not implemented", http.StatusNotImplemented) notImplemented(w)
return return
} }
} }
@@ -91,17 +129,13 @@ func ActionActionNamePost(w http.ResponseWriter, r *http.Request, params map[str
// ActionActionNamePut dispatches the PUT /action/{actionName} request. // ActionActionNamePut dispatches the PUT /action/{actionName} request.
func ActionActionNamePut(w http.ResponseWriter, r *http.Request, params map[string]string) { func ActionActionNamePut(w http.ResponseWriter, r *http.Request, params map[string]string) {
action := params["actionName"] action := params["actionName"]
var req types.ActionRequest if _, ok := decodeEnvelope(w, r); !ok {
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return return
} }
switch action { switch action {
default: default:
http.Error(w, "Action not implemented", http.StatusNotImplemented) notImplemented(w)
return return
} }
} }
@@ -111,11 +145,8 @@ func ActionActionNamePut(w http.ResponseWriter, r *http.Request, params map[stri
// verb and carries the same {payload} envelope. // verb and carries the same {payload} envelope.
func ActionActionNameDelete(w http.ResponseWriter, r *http.Request, params map[string]string) { func ActionActionNameDelete(w http.ResponseWriter, r *http.Request, params map[string]string) {
action := params["actionName"] action := params["actionName"]
var req types.ActionRequest req, ok := decodeEnvelope(w, r)
dec := json.NewDecoder(r.Body) if !ok {
dec.DisallowUnknownFields()
if err := dec.Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return return
} }
@@ -125,7 +156,7 @@ func ActionActionNameDelete(w http.ResponseWriter, r *http.Request, params map[s
case "removeSubscription": case "removeSubscription":
actions.RemoveSubscription(w, r, req.Payload) actions.RemoveSubscription(w, r, req.Payload)
default: default:
http.Error(w, "Action not implemented", http.StatusNotImplemented) notImplemented(w)
return return
} }
} }
+191
View File
@@ -0,0 +1,191 @@
// 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 (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// The dispatch layer routes by verb and answers in plain text. This test walks
// every routed action and every dispatch-level rejection and asserts the
// invariant the user requires: an error is never a bare status line — every
// response carries a human-readable message, and every JSON error carries both
// "code" and "message".
//
// Requests carry a short deadline: the shared-auth proxy actions need a live
// dcntrlized-auth (which this gate does not assume), and the assertion is about
// the error body, not about the proxy hop. The deadline bounds the unreachable
// hop so the walk stays fast.
const dispatchTimeout = 600 * time.Millisecond
var (
alertActions = []string{
"createAlert", "getAlert", "joinAlert", "listSubscriptions",
"removeSubscription", "triggerAlert", "disarmAlert", "alarmStatus",
"ackAlarm", "deleteAlert", "qrImage",
}
authProxyActions = []string{"login", "register", "refresh", "delete", "logout"}
readOnlyActions = []string{"getAlert", "listSubscriptions", "alarmStatus", "qrImage", "logout", "me"}
deleteActions = []string{"deleteAlert", "removeSubscription"}
)
type verbCase struct {
method string
handler func(http.ResponseWriter, *http.Request, map[string]string)
names []string
body bool
}
func verbs() []verbCase {
return []verbCase{
{http.MethodGet, ActionActionNameGet, readOnlyActions, false},
{http.MethodPost, ActionActionNamePost, append(append([]string{}, alertActions...), authProxyActions...), true},
{http.MethodPut, ActionActionNamePut, nil, true},
{http.MethodDelete, ActionActionNameDelete, deleteActions, true},
}
}
// serve runs one dispatch call with the given action name and body.
func serve(t *testing.T, vc verbCase, name, body string) *httptest.ResponseRecorder {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), dispatchTimeout)
defer cancel()
var req *http.Request
if vc.body {
req = httptest.NewRequest(vc.method, "/action/"+name, strings.NewReader(body))
} else {
req = httptest.NewRequest(vc.method, "/action/"+name, nil)
}
req = req.WithContext(ctx)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
vc.handler(rec, req, map[string]string{"actionName": name})
return rec
}
// assertReadableError is the invariant: non-empty body, and a complete JSON
// envelope when the response claims to be JSON.
func assertReadableError(t *testing.T, rec *httptest.ResponseRecorder, what string) {
t.Helper()
if rec.Code < 400 {
t.Fatalf("%s: expected an error status, got %d", what, rec.Code)
}
body := strings.TrimSpace(rec.Body.String())
if body == "" {
t.Fatalf("%s: status %d with an empty body — an error must carry a message", what, rec.Code)
}
if ct := rec.Header().Get("Content-Type"); strings.Contains(ct, "application/json") {
var envelope struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal([]byte(body), &envelope); err != nil {
t.Fatalf("%s: JSON error body does not parse: %v (%s)", what, err, body)
}
if strings.TrimSpace(envelope.Error.Code) == "" {
t.Fatalf("%s: JSON error without a code: %s", what, body)
}
if strings.TrimSpace(envelope.Error.Message) == "" {
t.Fatalf("%s: JSON error without a message: %s", what, body)
}
}
}
// TestUnknownActionsAreReadablePlainText walks GET/POST/PUT/DELETE with names no
// switch routes. Every one is 501 and every one must read.
func TestUnknownActionsAreReadablePlainText(t *testing.T) {
for _, vc := range verbs() {
for _, name := range []string{"nope", "getAlerts", "triggeralert", ""} {
rec := serve(t, vc, name, "{}")
if rec.Code != http.StatusNotImplemented {
t.Fatalf("%s /action/%s = %d, want 501", vc.method, name, rec.Code)
}
if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "text/plain") {
t.Fatalf("%s /action/%s Content-Type = %q, want text/plain", vc.method, name, ct)
}
if strings.TrimSpace(rec.Body.String()) == "" {
t.Fatalf("%s /action/%s: 501 with an empty body", vc.method, name)
}
}
}
}
// TestMalformedEnvelopesAreReadablePlainText covers the decoder failures that
// the dispatcher writes itself.
func TestMalformedEnvelopesAreReadablePlainText(t *testing.T) {
bodies := []string{
"not json at all",
`{"payload":{},"unknown":"top-level"}`,
`[1,2,3]`,
`"a string"`,
}
for _, vc := range verbs() {
if !vc.body {
continue
}
for _, body := range bodies {
rec := serve(t, vc, "getAlert", body)
if rec.Code != http.StatusBadRequest {
t.Fatalf("%s body %q = %d, want 400", vc.method, body, rec.Code)
}
if strings.TrimSpace(rec.Body.String()) == "" {
t.Fatalf("%s body %q: 400 with an empty body", vc.method, body)
}
}
}
}
// TestEveryRoutedActionHasReadableError is the route walk proper: every action
// the switches route, with a valid envelope and no access token, must answer
// with a readable error (401/403/404/409/500/502 depending on the action).
func TestEveryRoutedActionHasReadableError(t *testing.T) {
// Point the shared-auth proxy at a closed port so the proxy actions fail
// immediately (502 with a message) instead of depending on DNS or a live
// auth service. This keeps the walk hermetic and fast.
t.Setenv("AUTH_SERVICE_URL", "http://127.0.0.1:1")
for _, vc := range verbs() {
for _, name := range vc.names {
rec := serve(t, vc, name, "{}")
assertReadableError(t, rec, vc.method+" /action/"+name)
}
}
}
// TestPutIsReadableForKnownActions pins that PUT is routed but implements
// nothing: 501 for every name, known or not, with a message.
func TestPutIsReadableForKnownActions(t *testing.T) {
vc := verbCase{http.MethodPut, ActionActionNamePut, nil, true}
for _, name := range append(append([]string{}, alertActions...), "me", "logout") {
rec := serve(t, vc, name, "{}")
if rec.Code != http.StatusNotImplemented {
t.Fatalf("PUT /action/%s = %d, want 501", name, rec.Code)
}
if strings.TrimSpace(rec.Body.String()) == "" {
t.Fatalf("PUT /action/%s: 501 with an empty body", name)
}
}
}
+143
View File
@@ -0,0 +1,143 @@
// 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/>.
// This file makes "every error response carries a human-readable message" a
// structural property instead of a convention: Write and WriteText are the only
// emitters of an error status in the service, and both substitute a non-empty
// message when handed an empty one.
//
// It exists because an error status with no readable body is a client-breaking
// bug that no amount of review reliably catches: http.Error(w, "", 500) and a
// JSON envelope built without a message both look harmless at the call site.
package apierr
import (
"encoding/json"
"fmt"
"net/http"
"strings"
)
// fallbackMessage is the last resort for a code nobody registered.
const fallbackMessage = "unexpected error"
// defaultMessages is the canonical human-readable text for each wire code. The
// handlers usually send a more specific message; this is the floor.
var defaultMessages = map[string]string{
AlertNotFound: "alert not found",
AlertTokenInvalid: "the provided token does not match this alert",
AlertNotAdmin: "caller is not the alert admin",
AlertAlreadyJoined: "this device is already subscribed to this alert",
AlertNotSubscribed: "caller is not subscribed to this alert",
AlertState: "the alert is not in a state that allows this action",
AlarmTriggeredOffline: "an alarm was triggered while this device was offline",
Validation: "the request payload is not valid",
Unauthorized: "missing or invalid access token",
Internal: "internal error",
MethodNotAllowed: "method not allowed",
Unavailable: "service is unavailable",
}
// Entry describes one documented wire code.
type Entry struct {
Code string
Message string
}
// All returns every documented wire code with its default message, in a stable
// order (the order of the const block in codes.go).
func All() []Entry {
return []Entry{
{AlertNotFound, defaultMessages[AlertNotFound]},
{AlertTokenInvalid, defaultMessages[AlertTokenInvalid]},
{AlertNotAdmin, defaultMessages[AlertNotAdmin]},
{AlertAlreadyJoined, defaultMessages[AlertAlreadyJoined]},
{AlertNotSubscribed, defaultMessages[AlertNotSubscribed]},
{AlertState, defaultMessages[AlertState]},
{AlarmTriggeredOffline, defaultMessages[AlarmTriggeredOffline]},
{Validation, defaultMessages[Validation]},
{Unauthorized, defaultMessages[Unauthorized]},
{Internal, defaultMessages[Internal]},
{MethodNotAllowed, defaultMessages[MethodNotAllowed]},
{Unavailable, defaultMessages[Unavailable]},
}
}
// Message returns the canonical message for code. It never returns an empty
// string: an unknown or empty code gets a generic message (and Write upgrades an
// empty code to Internal before asking).
func Message(code string) string {
if m, ok := defaultMessages[code]; ok && strings.TrimSpace(m) != "" {
return m
}
return fallbackMessage
}
// ErrorBody is the platform error envelope.
type ErrorBody struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
// Write emits {"error":{"code","message"}} and is the only way this service
// writes a JSON error. An empty code becomes ERR_INTERNAL and an empty message
// becomes the code's canonical message, so the body always carries both a code
// and a readable message.
func Write(w http.ResponseWriter, status int, code, message string) {
code = strings.TrimSpace(code)
if code == "" {
code = Internal
}
message = strings.TrimSpace(message)
if message == "" {
message = Message(code)
}
if status < 400 {
// Callers only reach this on an error path; keep a nonsensical status
// from producing an apparently successful error response.
status = http.StatusInternalServerError
}
var body ErrorBody
body.Error.Code = code
body.Error.Message = message
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
}
// WriteText emits a plain-text error body (the dispatch-level responses). An
// empty message falls back to the status text and then to a generic phrase, so
// it can never write a bare status line.
func WriteText(w http.ResponseWriter, status int, message string) {
message = strings.TrimSpace(message)
if message == "" {
message = strings.TrimSpace(http.StatusText(status))
}
if message == "" {
message = fallbackMessage
}
if status < 400 {
status = http.StatusInternalServerError
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.WriteHeader(status)
_, _ = fmt.Fprintln(w, message)
}
+160
View File
@@ -0,0 +1,160 @@
// 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 apierr
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"regexp"
"strings"
"testing"
)
// TestEveryCodeHasMessage walks the documented codes and fails if any of them
// would emit an empty message.
func TestEveryCodeHasMessage(t *testing.T) {
entries := All()
if len(entries) == 0 {
t.Fatal("All() returned no codes")
}
for _, e := range entries {
if strings.TrimSpace(e.Code) == "" {
t.Fatalf("code entry with empty code: %+v", e)
}
if strings.TrimSpace(e.Message) == "" {
t.Fatalf("%s has no default message", e.Code)
}
if got := Message(e.Code); got != e.Message {
t.Fatalf("Message(%s) = %q, want %q", e.Code, got, e.Message)
}
}
}
// TestAllCoversEveryDeclaredCode reads codes.go and checks that no wire code
// constant exists without a default message registered in All(). Adding a code
// to the const block without registering it fails here rather than at runtime
// with a bodyless error.
func TestAllCoversEveryDeclaredCode(t *testing.T) {
src, err := os.ReadFile("codes.go")
if err != nil {
t.Fatalf("read codes.go: %v", err)
}
declared := regexp.MustCompile(`=\s*"(ERR_[A-Z_]+)"`).FindAllStringSubmatch(string(src), -1)
if len(declared) == 0 {
t.Fatal("found no ERR_ constants in codes.go")
}
known := make(map[string]bool, len(All()))
for _, e := range All() {
known[e.Code] = true
}
for _, m := range declared {
if !known[m[1]] {
t.Fatalf("code %s is declared in codes.go but missing from apierr.All() — it would have no default message", m[1])
}
}
}
// TestWriteFillsEmptyMessageAndCode pins the structural guarantee: the emitter,
// not the caller, is responsible for a readable body.
func TestWriteFillsEmptyMessageAndCode(t *testing.T) {
rec := httptest.NewRecorder()
Write(rec, http.StatusInternalServerError, "", "")
assertJSONError(t, rec, Internal, "")
rec = httptest.NewRecorder()
Write(rec, http.StatusForbidden, AlertTokenInvalid, " ")
assertJSONError(t, rec, AlertTokenInvalid, Message(AlertTokenInvalid))
rec = httptest.NewRecorder()
Write(rec, http.StatusTeapot, "ERR_NOT_A_REAL_CODE", "")
assertJSONError(t, rec, "ERR_NOT_A_REAL_CODE", fallbackMessage)
}
// TestWriteTextNeverEmpty covers the plain-text dispatch path, including the
// statuses it actually uses. This is the http.Error(w, "", 500) case.
func TestWriteTextNeverEmpty(t *testing.T) {
for _, status := range []int{
http.StatusBadRequest,
http.StatusMethodNotAllowed,
http.StatusNotImplemented,
http.StatusServiceUnavailable,
} {
rec := httptest.NewRecorder()
WriteText(rec, status, "")
body := strings.TrimSpace(rec.Body.String())
if rec.Code != status {
t.Fatalf("status %d: got %d", status, rec.Code)
}
if body == "" {
t.Fatalf("status %d: empty plain-text body", status)
}
if !strings.Contains(rec.Header().Get("Content-Type"), "text/plain") {
t.Fatalf("status %d: Content-Type = %q", status, rec.Header().Get("Content-Type"))
}
}
rec := httptest.NewRecorder()
WriteText(rec, http.StatusBadRequest, "invalid request body: boom")
if got := strings.TrimSpace(rec.Body.String()); got != "invalid request body: boom" {
t.Fatalf("message not passed through: %q", got)
}
}
// TestWriteAlwaysHasBothJSONFields is the shape assertion: {code, message} even
// when the caller supplies neither.
func TestWriteAlwaysHasBothJSONFields(t *testing.T) {
rec := httptest.NewRecorder()
Write(rec, http.StatusConflict, AlertState, "")
var body struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("body is not JSON: %v (%s)", err, rec.Body.String())
}
if body.Error.Code == "" || body.Error.Message == "" {
t.Fatalf("incomplete envelope: %s", rec.Body.String())
}
}
func assertJSONError(t *testing.T, rec *httptest.ResponseRecorder, wantCode, wantMessage string) {
t.Helper()
if !strings.Contains(rec.Header().Get("Content-Type"), "application/json") {
t.Fatalf("Content-Type = %q", rec.Header().Get("Content-Type"))
}
var body struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("body is not JSON: %v (%s)", err, rec.Body.String())
}
if body.Error.Code != wantCode {
t.Fatalf("code = %q, want %q", body.Error.Code, wantCode)
}
if wantMessage != "" && body.Error.Message != wantMessage {
t.Fatalf("message = %q, want %q", body.Error.Message, wantMessage)
}
if strings.TrimSpace(body.Error.Message) == "" {
t.Fatal("empty message in the error envelope")
}
}
+11
View File
@@ -28,7 +28,18 @@ const (
AlertAlreadyJoined = "ERR_ALERT_ALREADY_JOINED" AlertAlreadyJoined = "ERR_ALERT_ALREADY_JOINED"
AlertNotSubscribed = "ERR_ALERT_NOT_SUBSCRIBED" AlertNotSubscribed = "ERR_ALERT_NOT_SUBSCRIBED"
AlertState = "ERR_ALERT_STATE" AlertState = "ERR_ALERT_STATE"
// AlarmTriggeredOffline is NOT an error: it is the alarmStatus check-in
// answering 409 because this device still owes a trigger it missed while
// offline. The body carries the alarm frame and the client must ring.
AlarmTriggeredOffline = "ERR_ALARM_TRIGGERED_OFFLINE"
Validation = "ERR_VALIDATION" Validation = "ERR_VALIDATION"
Unauthorized = "ERR_UNAUTHORIZED" Unauthorized = "ERR_UNAUTHORIZED"
Internal = "ERR_INTERNAL" Internal = "ERR_INTERNAL"
// MethodNotAllowed and Unavailable are emitted on the /ws/alarm handshake
// only. They are JSON codes; the dispatch-level plain-text 405 carries no
// code (see API.md).
MethodNotAllowed = "ERR_METHOD_NOT_ALLOWED"
Unavailable = "ERR_UNAVAILABLE"
) )
+38
View File
@@ -0,0 +1,38 @@
// 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 loghash anonymises client identifiers for stdout request lines: plain
// SHA-256 hex, no salt, no key, no work factor (user ruling: keep it simple).
//
// Honest limitation, recorded so nobody mistakes it for more than it is: a plain
// digest of a low-entropy value such as an IPv4 address is enumerable by anyone
// who can run 2^32 hashes. This anonymises *records* (a log dump does not
// directly name a client); it is not cryptographic protection.
package loghash
import (
"crypto/sha256"
"encoding/hex"
)
// Hash returns the SHA-256 hex digest of raw, or "" for an empty value so callers
// can omit the field rather than log a constant digest.
func Hash(raw string) string {
if raw == "" {
return ""
}
sum := sha256.Sum256([]byte(raw))
return hex.EncodeToString(sum[:])
}
+34
View File
@@ -0,0 +1,34 @@
// 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 loghash
import "testing"
func TestHashIsStableSha256(t *testing.T) {
got := Hash("192.0.2.10")
if len(got) != 64 {
t.Fatalf("digest length = %d, want 64 hex chars", len(got))
}
if got != Hash("192.0.2.10") {
t.Fatal("hash is not stable")
}
if Hash("192.0.2.11") == got {
t.Fatal("different addresses collided")
}
if Hash("") != "" {
t.Fatal("empty identifier should map to empty")
}
}
+2 -1
View File
@@ -157,7 +157,8 @@ func Warn(format string, v ...any) {
func Error(format string, v ...any) { func Error(format string, v ...any) {
once.Do(initLogger) once.Do(initLogger)
file, line := callSite() file, line := callSite()
errorLogger.Output(0, fmt.Sprintf("%s:%d: %s", filepath.Base(file), line, fmt.Sprintf(format, v...))) msg := fmt.Sprintf(format, v...)
errorLogger.Output(0, fmt.Sprintf("%s:%d: %s", filepath.Base(file), line, msg))
} }
// Debug accepts a format string and an optional, arbitrary number of arguments. // Debug accepts a format string and an optional, arbitrary number of arguments.
+17 -3
View File
@@ -17,11 +17,13 @@ package middleware
import ( import (
"bufio" "bufio"
"fmt"
"net" "net"
"net/http" "net/http"
"strings" "strings"
"time" "time"
"git.dcntrlized.org/Dcntrlized/Alarm/internal/loghash"
"git.dcntrlized.org/Dcntrlized/Alarm/logger" "git.dcntrlized.org/Dcntrlized/Alarm/logger"
) )
@@ -49,19 +51,31 @@ func LoggingMiddleware(next http.Handler) http.Handler {
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK} rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r) next.ServeHTTP(rec, r)
logger.Info("%s request=%s %s path=%s status=%d ua=%q ip=%s duration=%s", // Identifiers are not written in the clear on stdout: IP and user agent
// go through plain SHA-256 (internal/loghash). They never reach durable
// storage at all — the event_log table has no network columns.
ipHash, uaHash := hashClient(ip, r.UserAgent())
line := fmt.Sprintf("%s request=%s %s path=%s status=%d ua=%s ip=%s duration=%s",
time.Now().Format(time.RFC3339), time.Now().Format(time.RFC3339),
r.Method, r.Method,
r.Proto, r.Proto,
r.URL.Path, r.URL.Path,
rec.status, rec.status,
r.UserAgent(), uaHash,
ip, ipHash,
time.Since(start), time.Since(start),
) )
logger.Info("%s", line)
}) })
} }
// hashClient anonymises request identifiers for stdout. The durable event log
// never stores network identifiers at all; this is only so a stdout request line
// does not name a client directly.
func hashClient(ip, ua string) (ipHash, uaHash string) {
return loghash.Hash(ip), loghash.Hash(ua)
}
// statusRecorder captures the response status without altering the response. // statusRecorder captures the response status without altering the response.
// It preserves http.Hijacker so the /ws/alarm upgrade still works when the // It preserves http.Hijacker so the /ws/alarm upgrade still works when the
// route is mounted behind this middleware. // route is mounted behind this middleware.
+81
View File
@@ -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[:])
}
+54
View File
@@ -16,10 +16,13 @@
package realtime package realtime
import ( import (
"context"
"encoding/json" "encoding/json"
"sync" "sync"
"time" "time"
"git.dcntrlized.org/Dcntrlized/Alarm/db"
"github.com/google/uuid"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
) )
@@ -107,6 +110,11 @@ func (c *client) readPump() {
c.acks++ c.acks++
c.ackMu.Unlock() c.ackMu.Unlock()
c.hub.recordAck(f.EventID) c.hub.recordAck(f.EventID)
// Parity with the offline check-in: the live frame was received and
// acknowledged, so this device no longer owes it. Without this an
// online client would also be told 409 ERR_ALARM_TRIGGERED_OFFLINE
// later — i.e. told twice about one alarm.
c.ackDelivery(f.EventID)
case "ping": case "ping":
c.touch() c.touch()
// Answer at the protocol level: the frozen contract defines no // Answer at the protocol level: the frozen contract defines no
@@ -116,6 +124,52 @@ func (c *client) readPump() {
} }
} }
// ackDelivery records a *receipt*: the client has the frame, so the service must
// not offer it again on the next check-in or reconnect.
//
// It deliberately does not dismiss the alarm. Clients ack automatically the
// moment a frame arrives — the person may still be asleep behind a lock screen
// and the siren is still sounding — so treating that ack as "dismissed" let the
// drain-to-empty rule destroy the alert 27 ms after the owner triggered it
// (measured on an Android 15 emulator: the phone was ringing for an alert that
// no longer existed). Dismissal is the explicit `ackAlarm` action; only that
// drives the drain.
//
// Best-effort and idempotent: a malformed event id, an admin connection (no
// subscription) or a repeated receipt are all silent no-ops. A database failure
// is logged, never fatal — the client already has the alarm, and the worst case
// is one duplicate check-in answer, not a dropped ring.
func (c *client) ackDelivery(eventID string) {
if c.identity.SubscriptionID == nil {
return
}
id, err := uuid.Parse(eventID)
if err != nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
marked, err := db.MarkDelivered(ctx, c.identity.AlertID, *c.identity.SubscriptionID, id)
if err != nil {
c.hub.opts.Logf("ws: delivery receipt failed alert=%s event=%s: %v", c.identity.AlertID, id, err)
return
}
if marked == 0 {
// A repeated receipt (or one for an event this device was never offered)
// is a no-op: nothing to record.
return
}
// 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)
}
}
// writePump owns all writes except the WriteControl calls in readPump (which // writePump owns all writes except the WriteControl calls in readPump (which
// gorilla explicitly allows concurrently). // gorilla explicitly allows concurrently).
func (c *client) writePump() { func (c *client) writePump() {
+93 -21
View File
@@ -43,7 +43,9 @@ import (
"sync" "sync"
"time" "time"
"git.dcntrlized.org/Dcntrlized/Alarm/db"
"git.dcntrlized.org/Dcntrlized/Alarm/internal/alertstate" "git.dcntrlized.org/Dcntrlized/Alarm/internal/alertstate"
"git.dcntrlized.org/Dcntrlized/Alarm/internal/apierr"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
) )
@@ -56,6 +58,9 @@ const (
DefaultSendBuffer = 16 DefaultSendBuffer = 16
DefaultMaxFrameSize = 4096 DefaultMaxFrameSize = 4096
// replayTimeout bounds the ledger read done during a handshake.
replayTimeout = 3 * time.Second
// ackCap bounds the per-event ack counters kept for observability. // ackCap bounds the per-event ack counters kept for observability.
ackCap = 256 ackCap = 256
) )
@@ -68,7 +73,6 @@ type AlarmFrame struct {
Action string `json:"action"` // "trigger" | "disarm" Action string `json:"action"` // "trigger" | "disarm"
Message string `json:"message"` Message string `json:"message"`
ServerTime string `json:"serverTime"` // RFC3339, UTC ServerTime string `json:"serverTime"` // RFC3339, UTC
AutoStopSeconds int `json:"autoStopSeconds"`
} }
// AckFrame is the server -> client acknowledgement of a client ack. // AckFrame is the server -> client acknowledgement of a client ack.
@@ -88,8 +92,11 @@ 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
} }
@@ -114,6 +121,10 @@ type Options struct {
Authenticate func(r *http.Request) (*Identity, error) Authenticate func(r *http.Request) (*Identity, error)
// Touch refreshes subscriptions.last_seen_at. Optional. // Touch refreshes subscriptions.last_seen_at. Optional.
Touch func(ctx context.Context, subscriptionID uuid.UUID) error Touch func(ctx context.Context, subscriptionID uuid.UUID) error
// PendingReplay returns the trigger frames a subscription still owes (the
// durable delivery ledger). Nil uses the real ledger; tests inject a fake.
// A replay marks nothing: the debt clears on the client's ack/check-in.
PendingReplay func(ctx context.Context, alertID, subscriptionID uuid.UUID) ([]AlarmFrame, error)
PingInterval time.Duration PingInterval time.Duration
PongWait time.Duration PongWait time.Duration
@@ -142,22 +153,50 @@ func (o Options) withDefaults() Options {
if o.Logf == nil { if o.Logf == nil {
o.Logf = log.Printf o.Logf = log.Printf
} }
if o.PendingReplay == nil {
o.PendingReplay = ledgerReplay
}
return o return o
} }
// ledgerReplay is the production PendingReplay: the durable delivery ledger,
// converted into the same frames the hub broadcasts. Keeping the conversion here
// (rather than in db) avoids a db -> realtime cycle; the ledger stays the source
// of truth either way.
func ledgerReplay(ctx context.Context, alertID, subscriptionID uuid.UUID) ([]AlarmFrame, error) {
rows, err := db.PendingReplay(ctx, alertID, subscriptionID)
if err != nil {
return nil, err
}
frames := make([]AlarmFrame, 0, len(rows))
for _, r := range rows {
message := ""
if r.Message != nil {
message = *r.Message
}
frames = append(frames, AlarmFrame{
Type: "alarm",
AlertID: alertID.String(),
EventID: r.EventID.String(),
Action: alertstate.KindTrigger,
Message: message,
ServerTime: r.ServerTime.UTC().Format(time.RFC3339Nano),
})
}
return frames, nil
}
type lastEvent struct { type lastEvent struct {
frame AlarmFrame frame AlarmFrame
at time.Time at time.Time
} }
func (e lastEvent) active(now time.Time) bool { // active reports whether the cached event is still live. With no auto-stop an
if e.frame.Action != alertstate.KindTrigger { // alarm is live exactly while it is a trigger that has not yet been superseded
return false // by a disarm — and the cache only ever holds the newest event, so checking the
} // action is the whole test.
if e.frame.AutoStopSeconds <= 0 { func (e lastEvent) active() bool {
return true return e.frame.Action == alertstate.KindTrigger
}
return now.Before(e.at.Add(time.Duration(e.frame.AutoStopSeconds) * time.Second))
} }
// Hub fans alarm frames out to every WebSocket client of an alert. // Hub fans alarm frames out to every WebSocket client of an alert.
@@ -204,7 +243,7 @@ func (h *Hub) Handler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet { if r.Method != http.MethodGet {
w.Header().Set("Allow", http.MethodGet) w.Header().Set("Allow", http.MethodGet)
writeJSONError(w, http.StatusMethodNotAllowed, "ERR_METHOD_NOT_ALLOWED", "method not allowed") writeJSONError(w, http.StatusMethodNotAllowed, apierr.MethodNotAllowed, "method not allowed")
return return
} }
@@ -215,7 +254,7 @@ func (h *Hub) Handler() http.HandlerFunc {
writeJSONError(w, authErr.Status, authErr.Code, authErr.Message) writeJSONError(w, authErr.Status, authErr.Code, authErr.Message)
return return
} }
writeJSONError(w, http.StatusUnauthorized, "ERR_UNAUTHORIZED", "unauthorized") writeJSONError(w, http.StatusUnauthorized, apierr.Unauthorized, "unauthorized")
return return
} }
@@ -223,13 +262,13 @@ func (h *Hub) Handler() http.HandlerFunc {
closed := h.closed closed := h.closed
h.mu.Unlock() h.mu.Unlock()
if closed { if closed {
writeJSONError(w, http.StatusServiceUnavailable, "ERR_UNAVAILABLE", "server is shutting down") writeJSONError(w, http.StatusServiceUnavailable, apierr.Unavailable, "server is shutting down")
return return
} }
conn, err := h.upgrader.Upgrade(w, r, nil) conn, err := h.upgrader.Upgrade(w, r, nil)
if err != nil { if err != nil {
// Upgrade already wrote the HTTP error response. // Upgrade writes its own HTTP error with a non-empty status text.
h.opts.Logf("ws: upgrade failed alert=%s: %v", identity.AlertID, err) h.opts.Logf("ws: upgrade failed alert=%s: %v", identity.AlertID, err)
return return
} }
@@ -237,10 +276,26 @@ func (h *Hub) Handler() http.HandlerFunc {
c := newClient(h, conn, *identity) c := newClient(h, conn, *identity)
h.register(c) h.register(c)
// Durable replay: frames this subscription still owes from the delivery
// ledger. This is what makes a native service holding the socket (with
// the JS layer dead) fire an alarm it missed — the FGS has no access
// token and therefore cannot call the alarmStatus check-in at all. Sent
// after register/upgrade, into the buffered queue, bounded by the ledger
// query's LIMIT.
replayed := make(map[string]bool, 2)
for _, f := range h.replayPending(*identity) {
if b, err := json.Marshal(f); err == nil {
replayed[f.EventID] = true
c.trySend(b)
}
}
// Reconnect catch-up: replay the last event only while it is still // Reconnect catch-up: replay the last event only while it is still
// live, so a client that reconnects mid-alarm fires immediately and a // live, so a client that reconnects mid-alarm fires immediately and a
// client reconnecting after the alarm is never re-alarmed. // client reconnecting after the alarm is never re-alarmed. Skipped when
if f, ok := h.lastActive(identity.AlertID); ok { // the ledger replay above already sent that event (a live trigger whose
// ledger row is still unacked would otherwise arrive twice).
if f, ok := h.lastActive(identity.AlertID); ok && !replayed[f.EventID] {
if b, err := json.Marshal(f); err == nil { if b, err := json.Marshal(f); err == nil {
c.trySend(b) c.trySend(b)
} }
@@ -260,6 +315,24 @@ func (h *Hub) Handler() http.HandlerFunc {
} }
} }
// replayPending queries the ledger for this exact (alert, subscription) and
// returns the frames owed. Admin connections have no subscription and owe
// nothing. A failure is logged and degrades to the last-event catch-up: a broken
// ledger read must not refuse a socket that can still receive live alarms.
func (h *Hub) replayPending(identity Identity) []AlarmFrame {
if identity.SubscriptionID == nil {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), replayTimeout)
defer cancel()
frames, err := h.opts.PendingReplay(ctx, identity.AlertID, *identity.SubscriptionID)
if err != nil {
h.opts.Logf("ws: pending delivery replay failed alert=%s: %v", identity.AlertID, err)
return nil
}
return frames
}
func (h *Hub) register(c *client) { func (h *Hub) register(c *client) {
h.mu.Lock() h.mu.Lock()
defer h.mu.Unlock() defer h.mu.Unlock()
@@ -344,7 +417,7 @@ func (h *Hub) lastActive(alertID uuid.UUID) (AlarmFrame, bool) {
h.mu.Lock() h.mu.Lock()
defer h.mu.Unlock() defer h.mu.Unlock()
e, ok := h.last[alertID] e, ok := h.last[alertID]
if !ok || !e.active(time.Now().UTC()) { if !ok || !e.active() {
return AlarmFrame{}, false return AlarmFrame{}, false
} }
return e.frame, true return e.frame, true
@@ -444,10 +517,9 @@ func (h *Hub) touch(c *client) {
} }
} }
// writeJSONError emits the handshake rejection envelope. apierr.Write supplies
// a canonical message when code/message are empty, so no rejection can answer a
// bare status.
func writeJSONError(w http.ResponseWriter, status int, code, message string) { func writeJSONError(w http.ResponseWriter, status int, code, message string) {
w.Header().Set("Content-Type", "application/json") apierr.Write(w, status, code, message)
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]any{
"error": map[string]string{"code": code, "message": message},
})
} }
+126 -1
View File
@@ -21,9 +21,11 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
"sync"
"testing" "testing"
"time" "time"
"git.dcntrlized.org/Dcntrlized/Alarm/internal/apierr"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
) )
@@ -197,7 +199,6 @@ func TestReconnectReplaysLiveTrigger(t *testing.T) {
Action: "trigger", Action: "trigger",
Message: "still ringing", Message: "still ringing",
ServerTime: time.Now().UTC().Format(time.RFC3339Nano), ServerTime: time.Now().UTC().Format(time.RFC3339Nano),
AutoStopSeconds: 300,
} }
if delivered := hub.Broadcast(alertID, frame); delivered != 0 { if delivered := hub.Broadcast(alertID, frame); delivered != 0 {
t.Fatalf("Broadcast with no clients delivered to %d, want 0", delivered) t.Fatalf("Broadcast with no clients delivered to %d, want 0", delivered)
@@ -227,6 +228,7 @@ func TestHandlerRejectsNonGet(t *testing.T) {
var body struct { var body struct {
Error struct { Error struct {
Code string `json:"code"` Code string `json:"code"`
Message string `json:"message"`
} `json:"error"` } `json:"error"`
} }
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
@@ -235,4 +237,127 @@ func TestHandlerRejectsNonGet(t *testing.T) {
if body.Error.Code == "" { if body.Error.Code == "" {
t.Fatalf("error body has no code: %+v", body) t.Fatalf("error body has no code: %+v", body)
} }
if strings.TrimSpace(body.Error.Message) == "" {
t.Fatalf("error body has no message: %+v", body)
}
}
// TestHandshakeRejectionAlwaysHasMessage proves the structural fallback: the
// Authenticate hook can hand the hub an AuthError with an empty or whitespace
// message and the handshake rejection still carries a readable one. A bare
// status on a failed upgrade is exactly the bug this pins.
func TestHandshakeRejectionAlwaysHasMessage(t *testing.T) {
alertID := uuid.New()
cases := []*AuthError{
{Status: http.StatusUnauthorized, Code: apierr.Unauthorized, Message: ""},
{Status: http.StatusForbidden, Code: apierr.AlertTokenInvalid, Message: " "},
{Status: http.StatusNotFound, Code: apierr.AlertNotSubscribed, Message: ""},
{Status: http.StatusServiceUnavailable, Code: apierr.Unavailable, Message: ""},
}
for _, authErr := range cases {
authErr := authErr
hub := NewHub(Options{
Authenticate: func(r *http.Request) (*Identity, error) { return nil, authErr },
Logf: func(format string, v ...any) { t.Logf(format, v...) },
})
srv := httptest.NewServer(hub.Handler())
resp, err := http.Get(srv.URL + "/ws/alarm?alertId=" + alertID.String())
if err != nil {
srv.Close()
t.Fatalf("GET handshake: %v", err)
}
var body struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
decodeErr := json.NewDecoder(resp.Body).Decode(&body)
resp.Body.Close()
srv.Close()
if resp.StatusCode != authErr.Status {
t.Fatalf("status = %d, want %d", resp.StatusCode, authErr.Status)
}
if decodeErr != nil {
t.Fatalf("rejection body is not JSON: %v", decodeErr)
}
if body.Error.Code != authErr.Code {
t.Fatalf("code = %q, want %q", body.Error.Code, authErr.Code)
}
if strings.TrimSpace(body.Error.Message) == "" {
t.Fatalf("%d %s: rejection carried an empty message", authErr.Status, authErr.Code)
}
}
}
// TestConnectReplaysPendingDeliveriesBeforeAnyTrigger is the durable-delivery
// half of task-4: a subscription that owes a trigger from the ledger receives it
// on connect, before anyone broadcasts anything. After the client acks (and the
// ledger row is cleared, simulated by the fake flipping `owed`), a second
// reconnect replays nothing.
func TestConnectReplaysPendingDeliveriesBeforeAnyTrigger(t *testing.T) {
alertID := uuid.New()
subscriptionID := uuid.New()
eventID := uuid.New()
serverTime := time.Date(2026, 10, 18, 17, 5, 0, 123000000, time.UTC)
var mu sync.Mutex
owed := true
hub := NewHub(Options{
Authenticate: func(*http.Request) (*Identity, error) {
id := Identity{AlertID: alertID, UserID: uuid.New(), SubscriptionID: &subscriptionID}
return &id, nil
},
PendingReplay: func(_ context.Context, gotAlert, gotSub uuid.UUID) ([]AlarmFrame, error) {
if gotAlert != alertID || gotSub != subscriptionID {
t.Errorf("replay queried (%s, %s), want (%s, %s)", gotAlert, gotSub, alertID, subscriptionID)
}
mu.Lock()
defer mu.Unlock()
if !owed {
return nil, nil
}
return []AlarmFrame{{
Type: "alarm",
AlertID: alertID.String(),
EventID: eventID.String(),
Action: "trigger",
Message: "missed while offline",
ServerTime: serverTime.Format(time.RFC3339Nano),
}}, nil
},
Logf: func(format string, v ...any) { t.Logf(format, v...) },
})
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
go hub.Run(ctx)
srv := httptest.NewServer(hub.Handler())
t.Cleanup(srv.Close)
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http")
first := dial(t, wsURL)
got := readAlarm(t, first)
if got.EventID != eventID.String() || got.Message != "missed while offline" || got.Action != "trigger" {
t.Fatalf("replay frame = %+v, want the owed trigger %s", got, eventID)
}
if err := first.WriteJSON(ClientFrame{Type: "ack", EventID: eventID.String()}); err != nil {
t.Fatalf("ack: %v", err)
}
// Give the ack a moment to be processed by the read pump, then simulate the
// ledger row having been cleared by it (AckDelivery is covered against a real
// database in db/deliveries_db_test.go).
time.Sleep(50 * time.Millisecond)
mu.Lock()
owed = false
mu.Unlock()
second := dial(t, wsURL)
if err := second.SetReadDeadline(time.Now().Add(300 * time.Millisecond)); err != nil {
t.Fatalf("set deadline: %v", err)
}
if _, _, err := second.ReadMessage(); err == nil {
t.Fatal("reconnect after ack replayed a frame, want silence")
}
} }
-1
View File
@@ -50,7 +50,6 @@ func TestBroadcastLatency(t *testing.T) {
Action: "trigger", Action: "trigger",
Message: "latency probe", Message: "latency probe",
ServerTime: time.Now().UTC().Format(time.RFC3339Nano), ServerTime: time.Now().UTC().Format(time.RFC3339Nano),
AutoStopSeconds: 300,
} }
start := time.Now() start := time.Now()
if delivered := hub.Broadcast(alertID, frame); delivered != 2 { if delivered := hub.Broadcast(alertID, frame); delivered != 2 {
+1 -3
View File
@@ -40,13 +40,12 @@ type NotifyPayload struct {
EventID string `json:"eventId"` EventID string `json:"eventId"`
Action string `json:"action"` Action string `json:"action"`
Message string `json:"message"` Message string `json:"message"`
AutoStopSeconds int `json:"autoStopSeconds"`
CreatedAtMs int64 `json:"createdAtMs"` CreatedAtMs int64 `json:"createdAtMs"`
} }
// Frame converts the transport payload into the wire frame. ServerTime is the // Frame converts the transport payload into the wire frame. ServerTime is the
// EVENT time, not the delivery time, so every instance and every client agrees // EVENT time, not the delivery time, so every instance and every client agrees
// on when the alarm started (and therefore on the auto-stop countdown). // on when the alarm started.
func (p NotifyPayload) Frame() AlarmFrame { func (p NotifyPayload) Frame() AlarmFrame {
return AlarmFrame{ return AlarmFrame{
Type: "alarm", Type: "alarm",
@@ -55,7 +54,6 @@ func (p NotifyPayload) Frame() AlarmFrame {
Action: p.Action, Action: p.Action,
Message: p.Message, Message: p.Message,
ServerTime: time.UnixMilli(p.CreatedAtMs).UTC().Format(time.RFC3339Nano), ServerTime: time.UnixMilli(p.CreatedAtMs).UTC().Format(time.RFC3339Nano),
AutoStopSeconds: p.AutoStopSeconds,
} }
} }