Compare commits
10
Commits
0334d7fe93
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f1ac9c25b | ||
|
|
ca2130258b | ||
|
|
0e24c4e4f0 | ||
|
|
914fd6570d | ||
|
|
3cd04105bb | ||
|
|
6873b1a3f1 | ||
|
|
55e83d6bfd | ||
|
|
028f9e7dae | ||
|
|
3ccea3e9f6 | ||
|
|
6e6a1e9c8a |
+3
-6
@@ -3,9 +3,6 @@ src/tmp/
|
||||
src/logs/
|
||||
|
||||
# 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/*/.
|
||||
src/handlers/config/logs/
|
||||
src/handlers/manifest/logs/
|
||||
src/actions/logs/
|
||||
src/realtime/logs/
|
||||
src/db/logs/
|
||||
# tests (which run with the package dir as cwd) drop logs under any src/*/.
|
||||
# Glob every package so a new test package cannot leak a stray log file.
|
||||
src/**/logs/
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
| 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`,
|
||||
stored only as a SHA-256 hash server-side, and required by the destructive
|
||||
actions.
|
||||
- `subscribeToken` — the shareable secret rendered as the QR code. It is
|
||||
*rotatable* and is the only thing a joining client needs.
|
||||
- `subscribeToken` — the shareable secret rendered as the QR code. It is minted
|
||||
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
|
||||
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_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_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_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
|
||||
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
|
||||
|
||||
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) |
|
||||
| `triggerAlert` | 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 |
|
||||
| `qrImage` | GET, POST | — | admin **or** subscriber |
|
||||
| `rotateSubscribeToken` | POST | required | admin |
|
||||
|
||||
### Shared-auth actions (`/auth/*` proxies)
|
||||
|
||||
@@ -162,7 +213,7 @@ alert shapes documented here).
|
||||
| --- | --- | --- | --- |
|
||||
| `me` | GET | `GET /auth/me` | — (access token header only) |
|
||||
| `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": "…" }` |
|
||||
| `refresh` | POST | `POST /auth/refresh` | `{ "refreshToken": "…" }` |
|
||||
| `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
|
||||
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`
|
||||
@@ -182,8 +263,7 @@ Creates an alert and makes the caller its admin.
|
||||
```json
|
||||
{ "payload": {
|
||||
"name": "Fire drill",
|
||||
"message": "Leave the building now",
|
||||
"autoStopSeconds": 300
|
||||
"message": "Leave the building now"
|
||||
} }
|
||||
```
|
||||
|
||||
@@ -191,7 +271,6 @@ Creates an alert and makes the caller its admin.
|
||||
| --- | --- | --- |
|
||||
| `name` | string | required, 1–255 characters after trimming |
|
||||
| `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`:
|
||||
|
||||
@@ -205,8 +284,6 @@ Response `200`:
|
||||
"adminToken": "…", // shown ONCE, store it
|
||||
"subscribeToken": "…",
|
||||
"qrPayload": "https://alarm.dcntrlized.org/a/0f8f…e#…",
|
||||
"autoStopSeconds": 300,
|
||||
"tokenVersion": 1,
|
||||
"created": "2026-10-18T17:00:00Z"
|
||||
}
|
||||
```
|
||||
@@ -214,6 +291,19 @@ Response `200`:
|
||||
`adminToken` is returned **only here** (and nowhere else, ever). The server
|
||||
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`.
|
||||
|
||||
## `getAlert`
|
||||
@@ -231,8 +321,6 @@ Response `200`:
|
||||
"message": "Leave the building now",
|
||||
"status": "triggered", // "armed" | "triggered" | "disarmed"
|
||||
"active": true, // derived, see alarmStatus
|
||||
"autoStopSeconds": 300,
|
||||
"tokenVersion": 1,
|
||||
"created": "2026-10-18T17:00:00Z",
|
||||
"modified": "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.
|
||||
|
||||
**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
|
||||
{ "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**:
|
||||
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
|
||||
rotate it — a new value makes the same user a separate subscription, which is
|
||||
once per install (a random value persisted in app storage is enough) and never
|
||||
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
|
||||
`/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`).
|
||||
|
||||
**Idempotent per `(alertId, user, deviceId)`**: re-scanning the same QR on the
|
||||
@@ -310,7 +408,7 @@ is *not* required for this read).
|
||||
|
||||
```json
|
||||
{ "items": [
|
||||
{ "id": "…", "userId": "…", "deviceId": "android-8f2c…",
|
||||
{ "id": "…", "userId": "…",
|
||||
"deviceLabel": "Pixel 7", "platform": "android",
|
||||
"enabled": true, "lastSeenAt": "2026-10-18T17:04: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
|
||||
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`,
|
||||
`ERR_UNAUTHORIZED`.
|
||||
|
||||
@@ -367,7 +478,6 @@ Response `200`:
|
||||
"eventId": "…",
|
||||
"action": "trigger",
|
||||
"serverTime": "2026-10-18T17:05:00.123Z",
|
||||
"autoStopSeconds": 300,
|
||||
"delivered": 2
|
||||
}
|
||||
```
|
||||
@@ -377,8 +487,45 @@ Response `200`:
|
||||
not connected (or are on another instance) are reached by the reconnect
|
||||
catch-up and by the `alarmStatus` fallback instead.
|
||||
- Re-triggering an already-triggered alert is allowed and always produces a
|
||||
**new `eventId`**, restarting the auto-stop window. Clients must treat each
|
||||
`eventId` as a new alarm.
|
||||
**new `eventId`**. Clients must treat each `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
|
||||
immediately after the call still sees the live event.
|
||||
|
||||
@@ -401,29 +548,33 @@ Response `200` — same shape as `triggerAlert` with `"action": "disarm"`:
|
||||
"eventId": "…",
|
||||
"action": "disarm",
|
||||
"serverTime": "2026-10-18T17:09:00.456Z",
|
||||
"autoStopSeconds": 300,
|
||||
"delivered": 2
|
||||
}
|
||||
```
|
||||
|
||||
`autoStopSeconds` echoes the alert's **configured** value (e.g. `300`), not
|
||||
`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.
|
||||
Disarm **ends the alert**, whatever its state:
|
||||
|
||||
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`
|
||||
|
||||
The cheap, indexed fallback a client polls when its socket is down, or on cold
|
||||
start when it may have been killed mid-alarm.
|
||||
|
||||
**GET** `/action/alarmStatus?alertId=<uuid>` ·
|
||||
**POST** `{ "payload": { "alertId": "…" } }`
|
||||
**GET** `/action/alarmStatus?alertId=<uuid>[&deviceId=<raw id>]` ·
|
||||
**POST** `{ "payload": { "alertId": "…" } }` (a `deviceId` in the payload is not
|
||||
read; send it in the query if you want to pin the subscription)
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -431,21 +582,137 @@ start when it may have been killed mid-alarm.
|
||||
"eventId": "…",
|
||||
"action": "trigger",
|
||||
"message": "Leave the building now",
|
||||
"serverTime": "2026-10-18T17:05:00.123Z",
|
||||
"autoStopSeconds": 300
|
||||
"serverTime": "2026-10-18T17:05:00.123Z"
|
||||
}
|
||||
```
|
||||
|
||||
`active` is **derived**, never a timer: it is true only when the newest event is
|
||||
a `trigger`, the stored status is `triggered`, and (when `autoStopSeconds > 0`)
|
||||
`now < triggeredAt + autoStopSeconds`. Nothing sweeps the database, so the
|
||||
answer stays correct across restarts and across instances. When there is no
|
||||
event yet the fields are empty and `active` is `false`.
|
||||
`active` is **derived**, never a ringing timer: it is true only when the newest
|
||||
event is a `trigger`, the stored status is `triggered`, **and this caller still
|
||||
owes the alarm** (an unacknowledged delivery row for its subscription). The
|
||||
answer stays correct across restarts and instances, and it stays `true` until
|
||||
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
|
||||
event) — use it to correct client clock skew rather than comparing with the
|
||||
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`,
|
||||
`ERR_UNAUTHORIZED`.
|
||||
|
||||
@@ -457,7 +724,8 @@ Errors: `ERR_VALIDATION`, `ERR_ALERT_NOT_FOUND`, `ERR_ALERT_NOT_SUBSCRIBED`,
|
||||
{ "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`:
|
||||
|
||||
```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`,
|
||||
`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
|
||||
|
||||
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`
|
||||
|
||||
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
|
||||
user and the second token proves the relationship to the alert.
|
||||
Through an instance (the supported path) that is
|
||||
`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
|
||||
|
||||
@@ -586,16 +842,13 @@ from one fan-out:
|
||||
"eventId": "…",
|
||||
"action": "trigger", // "trigger" | "disarm"
|
||||
"message": "Leave the building now",
|
||||
"serverTime": "2026-10-18T17:05:00.123Z",
|
||||
"autoStopSeconds": 300
|
||||
"serverTime": "2026-10-18T17:05:00.123Z"
|
||||
}
|
||||
```
|
||||
|
||||
`ack` — the server's acknowledgement of a client ack:
|
||||
|
||||
```json
|
||||
{ "type": "ack", "eventId": "…" }
|
||||
```
|
||||
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
|
||||
must not wait for one.
|
||||
|
||||
### Client → server frames
|
||||
|
||||
@@ -633,11 +886,43 @@ candidate; the same applies to sending non-JSON frames.
|
||||
|
||||
### Reconnect catch-up
|
||||
|
||||
The service keeps the **last event per alert** in memory. On connect, if that
|
||||
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
|
||||
without polling, and a client that reconnects *after* the alarm is over is never
|
||||
re-alarmed.
|
||||
Two replays happen on a successful upgrade, both before anything else is sent:
|
||||
|
||||
1. **Durable ledger replay (the authoritative one).** Every trigger writes a
|
||||
delivery-ledger row per enabled subscription. When a socket connects, the
|
||||
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
|
||||
|
||||
@@ -651,15 +936,16 @@ subscribers of instance B as well. Duplicate deliveries are suppressed by
|
||||
### Minimal client
|
||||
|
||||
```js
|
||||
// Through an instance: wss://<instance>/api/alarm/ws/alarm?…
|
||||
const ws = new WebSocket(
|
||||
`ws://alarm:8088/ws/alarm?alertId=${alertId}&token=${subscribeToken}` +
|
||||
`&accessToken=${accessToken}&deviceId=${deviceId}`);
|
||||
`wss://${instance}/api/alarm/ws/alarm?alertId=${alertId}` +
|
||||
`&token=${subscribeToken}&accessToken=${accessToken}&deviceId=${deviceId}`);
|
||||
|
||||
ws.onmessage = (ev) => {
|
||||
const f = JSON.parse(ev.data);
|
||||
if (f.type === "alarm" && f.action === "trigger") {
|
||||
// autoStopSeconds: 0 = ring until a disarm arrives; >0 = self-stop after N
|
||||
ring(f.message, f.eventId, f.autoStopSeconds); // max volume, wake screen
|
||||
// No auto-stop: ring until a disarm frame (or a local dismissal) arrives.
|
||||
ring(f.message, f.eventId); // max volume, wake screen
|
||||
ws.send(JSON.stringify({ type: "ack", eventId: 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
|
||||
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`
|
||||
@@ -699,6 +998,8 @@ mount the shared-auth proxy.
|
||||
## Worked example
|
||||
|
||||
```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
|
||||
|
||||
# 1. access token (shared auth)
|
||||
@@ -709,7 +1010,7 @@ TOKEN=$(curl -s -X POST "$BASE/auth/login" \
|
||||
# 2. create the alert (keep adminToken!)
|
||||
curl -s -X POST "$BASE/action/createAlert" -H "X-Access-Token: $TOKEN" \
|
||||
-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
|
||||
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" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-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
|
||||
@@ -736,9 +1043,75 @@ All state lives in the shared PostgreSQL database, schema `alarm`:
|
||||
|
||||
| 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.subscriptions` | `(alert, user, device)` unique, label/platform, `enabled`, `last_seen_at` |
|
||||
| `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, 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_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
|
||||
`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
|
||||
$$;
|
||||
@@ -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
|
||||
$$;
|
||||
@@ -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 $$;
|
||||
@@ -12,6 +12,15 @@ services:
|
||||
image: 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
|
||||
# directly — no pre-build step needed. Do NOT add `network: host` here:
|
||||
# `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.
|
||||
- DCNTRLIZED_DEBUG=true
|
||||
|
||||
# Shared auth service target (dcntrlized-auth on the dcntrlized network)
|
||||
- AUTH_SERVICE_URL=http://dcntrlized-auth:8080
|
||||
# Shared auth service target (dcntrlized-auth on the dcntrlized network).
|
||||
# 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)
|
||||
- DCNTRLIZED_SERVICE_NAME=alarm
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
+19
-10
@@ -281,7 +281,7 @@ func CreateAlert(w http.ResponseWriter, r *http.Request, payload any) {
|
||||
// admin and by any subscribed user.
|
||||
func GetAlert(w http.ResponseWriter, r *http.Request, payload any) {
|
||||
const action = "getAlert"
|
||||
p, ok := principal(w, r, action)
|
||||
c, ok := callerFor(w, r, action)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -296,16 +296,25 @@ func GetAlert(w http.ResponseWriter, r *http.Request, payload any) {
|
||||
dbError(w, action, err)
|
||||
return
|
||||
}
|
||||
isAdmin := res.Alert.AdminUserID == p.ID
|
||||
isAdmin := !c.IsDevice && res.Alert.AdminUserID == c.UserID
|
||||
if !isAdmin {
|
||||
allowed, err := db.IsSubscriber(r.Context(), alertID, p.ID)
|
||||
if err != nil {
|
||||
dbError(w, action, err)
|
||||
return
|
||||
}
|
||||
if !allowed {
|
||||
writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "not subscribed to this alert")
|
||||
return
|
||||
if c.IsDevice {
|
||||
// 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 {
|
||||
dbError(w, action, err)
|
||||
return
|
||||
}
|
||||
if !allowed {
|
||||
writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "not subscribed to this alert")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
count, err := db.CountSubscriptions(r.Context(), alertID)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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[:])
|
||||
}
|
||||
+26
-32
@@ -27,11 +27,11 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.dcntrlized.org/Dcntrlized/Alarm/db"
|
||||
"git.dcntrlized.org/Dcntrlized/Alarm/internal/alertstate"
|
||||
"git.dcntrlized.org/Dcntrlized/Alarm/internal/apierr"
|
||||
"git.dcntrlized.org/dcntrlized/dcntrlized-auth/authclient"
|
||||
"github.com/google/uuid"
|
||||
@@ -40,15 +40,16 @@ import (
|
||||
// Frozen v1 error codes, aliased from internal/apierr so the WebSocket layer
|
||||
// (which cannot import this package) uses the same strings.
|
||||
const (
|
||||
CodeAlertNotFound = apierr.AlertNotFound
|
||||
CodeAlertTokenInvalid = apierr.AlertTokenInvalid
|
||||
CodeAlertNotAdmin = apierr.AlertNotAdmin
|
||||
CodeAlertAlreadyJoined = apierr.AlertAlreadyJoined
|
||||
CodeAlertNotSubscribed = apierr.AlertNotSubscribed
|
||||
CodeAlertState = apierr.AlertState
|
||||
CodeValidation = apierr.Validation
|
||||
CodeUnauthorized = apierr.Unauthorized
|
||||
CodeInternal = apierr.Internal
|
||||
CodeAlertNotFound = apierr.AlertNotFound
|
||||
CodeAlertTokenInvalid = apierr.AlertTokenInvalid
|
||||
CodeAlertNotAdmin = apierr.AlertNotAdmin
|
||||
CodeAlertAlreadyJoined = apierr.AlertAlreadyJoined
|
||||
CodeAlertNotSubscribed = apierr.AlertNotSubscribed
|
||||
CodeAlertState = apierr.AlertState
|
||||
CodeAlarmTriggeredOffline = apierr.AlarmTriggeredOffline
|
||||
CodeValidation = apierr.Validation
|
||||
CodeUnauthorized = apierr.Unauthorized
|
||||
CodeInternal = apierr.Internal
|
||||
)
|
||||
|
||||
// maxMessageBytes / maxNameChars bound the free-text fields.
|
||||
@@ -60,22 +61,11 @@ const (
|
||||
maxPlatformLen = 32
|
||||
)
|
||||
|
||||
// errorEnvelope is the platform error body.
|
||||
type errorEnvelope struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
// writeError emits {"error":{"code","message"}}.
|
||||
// writeError emits {"error":{"code","message"}}. It delegates to apierr.Write,
|
||||
// which guarantees both fields are non-empty: this package can pass a literal
|
||||
// message, but the invariant is enforced centrally (see internal/apierr).
|
||||
func writeError(w http.ResponseWriter, status int, code, message string) {
|
||||
var body errorEnvelope
|
||||
body.Error.Code = code
|
||||
body.Error.Message = message
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
apierr.Write(w, status, code, message)
|
||||
}
|
||||
|
||||
// writeJSON emits a success body.
|
||||
@@ -119,6 +109,17 @@ func principal(w http.ResponseWriter, r *http.Request, action string) (*authclie
|
||||
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.
|
||||
//
|
||||
// Unknown fields are deliberately ignored: the clients are shipped separately
|
||||
@@ -180,13 +181,6 @@ func validateMessage(raw string) (string, error) {
|
||||
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) {
|
||||
id := strings.TrimSpace(raw)
|
||||
if id == "" {
|
||||
|
||||
+60
-15
@@ -187,7 +187,7 @@ type statusView struct {
|
||||
// background job is needed to expire anything.
|
||||
func AlarmStatus(w http.ResponseWriter, r *http.Request, payload any) {
|
||||
const action = "alarmStatus"
|
||||
p, ok := principal(w, r, action)
|
||||
c, ok := callerFor(w, r, action)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -202,14 +202,21 @@ func AlarmStatus(w http.ResponseWriter, r *http.Request, payload any) {
|
||||
dbError(w, action, err)
|
||||
return
|
||||
}
|
||||
allowed, err := isAdminOrSubscriber(r, res.Alert, p.ID)
|
||||
if err != nil {
|
||||
dbError(w, action, err)
|
||||
return
|
||||
}
|
||||
if !allowed {
|
||||
writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "not subscribed to this alert")
|
||||
return
|
||||
if c.IsDevice {
|
||||
if !c.subscribedTo(alertID) {
|
||||
writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "not subscribed to this alert")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
allowed, err := isAdminOrSubscriber(r, res.Alert, c.UserID)
|
||||
if err != nil {
|
||||
dbError(w, action, err)
|
||||
return
|
||||
}
|
||||
if !allowed {
|
||||
writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "not subscribed to this alert")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Offline check-in, READ-ONLY. If this device still owes an alarm, answer
|
||||
@@ -219,13 +226,30 @@ 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
|
||||
// that case falls through to the normal body (active:false).
|
||||
deviceHash := ""
|
||||
if raw := strings.TrimSpace(r.URL.Query().Get("deviceId")); raw != "" {
|
||||
deviceHash = deviceid.Hash(raw)
|
||||
if !c.IsDevice {
|
||||
if raw := strings.TrimSpace(r.URL.Query().Get("deviceId")); raw != "" {
|
||||
deviceHash = deviceid.Hash(raw)
|
||||
}
|
||||
}
|
||||
pending, err := db.PendingTrigger(r.Context(), alertID, p.ID, deviceHash)
|
||||
if err != nil && !errors.Is(err, db.ErrNotFound) {
|
||||
dbError(w, action, err)
|
||||
return
|
||||
var pending *db.PendingDelivery
|
||||
if c.IsDevice {
|
||||
// The device token already identifies the subscription, so its whole
|
||||
// pending set is the debt to report.
|
||||
rows, err := db.PendingReplay(r.Context(), alertID, c.Sub.ID)
|
||||
if err != nil && !errors.Is(err, db.ErrNotFound) {
|
||||
dbError(w, action, err)
|
||||
return
|
||||
}
|
||||
if len(rows) > 0 {
|
||||
pending = &rows[0]
|
||||
}
|
||||
} else {
|
||||
var err error
|
||||
pending, err = db.PendingTrigger(r.Context(), alertID, c.UserID, deviceHash)
|
||||
if err != nil && !errors.Is(err, db.ErrNotFound) {
|
||||
dbError(w, action, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if pending != nil && res.Event != nil && res.Event.Kind == alertstate.KindTrigger {
|
||||
writeOfflineAlarm(w, res.Alert, pending)
|
||||
@@ -239,6 +263,27 @@ func AlarmStatus(w http.ResponseWriter, r *http.Request, payload any) {
|
||||
// not live even if the stored status says "triggered".
|
||||
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{
|
||||
Active: active,
|
||||
|
||||
+16
-9
@@ -40,7 +40,7 @@ const (
|
||||
// through createAlert/qrPayload, the subscriber through the token they scanned).
|
||||
func QRImage(w http.ResponseWriter, r *http.Request, payload any) {
|
||||
const action = "qrImage"
|
||||
p, ok := principal(w, r, action)
|
||||
c, ok := callerFor(w, r, action)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -55,14 +55,21 @@ func QRImage(w http.ResponseWriter, r *http.Request, payload any) {
|
||||
dbError(w, action, err)
|
||||
return
|
||||
}
|
||||
allowed, err := isAdminOrSubscriber(r, alert, p.ID)
|
||||
if err != nil {
|
||||
dbError(w, action, err)
|
||||
return
|
||||
}
|
||||
if !allowed {
|
||||
writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "not subscribed to this alert")
|
||||
return
|
||||
if c.IsDevice {
|
||||
if !c.subscribedTo(alertID) {
|
||||
writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "not subscribed to this alert")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
allowed, err := isAdminOrSubscriber(r, alert, c.UserID)
|
||||
if err != nil {
|
||||
dbError(w, action, err)
|
||||
return
|
||||
}
|
||||
if !allowed {
|
||||
writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "not subscribed to this alert")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
scale, err := intQuery(r, "scale", defaultQRScale, 1, qr.MaxScale)
|
||||
|
||||
+104
-26
@@ -28,7 +28,7 @@ import (
|
||||
|
||||
type subscriptionView struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"userId"`
|
||||
UserID string `json:"userId,omitempty"`
|
||||
Username string `json:"username,omitempty"`
|
||||
DeviceLabel string `json:"deviceLabel,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
|
||||
// field is omitted rather than sent empty.
|
||||
func newSubscriptionView(s *db.Subscription) subscriptionView {
|
||||
return subscriptionView{
|
||||
view := subscriptionView{
|
||||
ID: s.ID.String(),
|
||||
UserID: s.SubscriberUserID.String(),
|
||||
DeviceLabel: deref(s.DeviceLabel),
|
||||
Platform: deref(s.Platform),
|
||||
Enabled: s.Enabled,
|
||||
LastSeenAt: rfc3339Ptr(s.LastSeenAt),
|
||||
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 --------------------------------------------------------------
|
||||
|
||||
// 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 {
|
||||
AlertID string `json:"alertId"`
|
||||
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.
|
||||
func JoinAlert(w http.ResponseWriter, r *http.Request, payload any) {
|
||||
const action = "joinAlert"
|
||||
p, ok := principal(w, r, action)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req joinRequest
|
||||
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)
|
||||
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)) {
|
||||
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")
|
||||
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 {
|
||||
case err == nil:
|
||||
Debug(action, "POST", "idempotent re-join alert=%s sub=%s", alertID, existing.ID)
|
||||
writeJSON(w, http.StatusOK, newSubscriptionView(existing))
|
||||
token, terr := mintDeviceToken()
|
||||
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
|
||||
case !errors.Is(err, db.ErrNotFound):
|
||||
dbError(w, action, err)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := mintDeviceToken()
|
||||
if err != nil {
|
||||
dbError(w, action, err)
|
||||
return
|
||||
}
|
||||
tokenHash := hashDeviceToken(token)
|
||||
sub, err := db.CreateSubscription(r.Context(), &db.Subscription{
|
||||
AlertID: alertID,
|
||||
SubscriberUserID: p.ID,
|
||||
DeviceID: storedDeviceID,
|
||||
DeviceLabel: label,
|
||||
Platform: platform,
|
||||
AlertID: alertID,
|
||||
DeviceID: storedDeviceID,
|
||||
DeviceLabel: label,
|
||||
Platform: platform,
|
||||
DeviceTokenHash: &tokenHash,
|
||||
})
|
||||
if errors.Is(err, db.ErrConflict) {
|
||||
// Two concurrent joins for the same device: the sequential repeat path
|
||||
// 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")
|
||||
return
|
||||
}
|
||||
@@ -162,7 +200,42 @@ func JoinAlert(w http.ResponseWriter, r *http.Request, payload any) {
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -203,7 +276,7 @@ type removeSubscriptionRequest struct {
|
||||
// alert's admin may remove any device; a subscriber may remove their own.
|
||||
func RemoveSubscription(w http.ResponseWriter, r *http.Request, payload any) {
|
||||
const action = "removeSubscription"
|
||||
p, ok := principal(w, r, action)
|
||||
c, ok := callerFor(w, r, action)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -237,17 +310,22 @@ func RemoveSubscription(w http.ResponseWriter, r *http.Request, payload any) {
|
||||
return
|
||||
}
|
||||
|
||||
if sub.SubscriberUserID != p.ID {
|
||||
// Not the owner: only the alert admin may remove someone else's device.
|
||||
// A stranger gets the same code as a missing row so the endpoint never
|
||||
// confirms that the subscription exists.
|
||||
// A device token is scoped to its own subscription: it can remove that row
|
||||
// and nothing else. An account may remove its own device, or any device on
|
||||
// an alert it administers.
|
||||
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)
|
||||
if err != nil {
|
||||
dbError(w, action, err)
|
||||
return
|
||||
}
|
||||
if alert.AdminUserID != p.ID {
|
||||
Warn(action, "auth", "user=%s may not remove sub=%s", p.ID, subscriptionID)
|
||||
if alert.AdminUserID != c.UserID {
|
||||
Warn(action, "auth", "user=%s may not remove sub=%s", c.UserID, subscriptionID)
|
||||
writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "subscription not found")
|
||||
return
|
||||
}
|
||||
@@ -257,7 +335,7 @@ func RemoveSubscription(w http.ResponseWriter, r *http.Request, payload any) {
|
||||
dbError(w, action, err)
|
||||
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{
|
||||
"alertId": alertID.String(),
|
||||
"subscriptionId": subscriptionID.String(),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -112,6 +112,12 @@ func Close() {
|
||||
if instance != nil && instance.Pool != nil {
|
||||
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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+10
-4
@@ -27,9 +27,11 @@ import (
|
||||
|
||||
// Event is one alarm.alarm_events row (append-only trigger/disarm log).
|
||||
type Event struct {
|
||||
ID uuid.UUID
|
||||
AlertID uuid.UUID
|
||||
Kind string
|
||||
ID uuid.UUID
|
||||
AlertID uuid.UUID
|
||||
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
|
||||
Message *string
|
||||
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) {
|
||||
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) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
+70
-11
@@ -25,6 +25,12 @@ import (
|
||||
)
|
||||
|
||||
// Subscription is one alarm.subscriptions row: a device that receives the alarm.
|
||||
//
|
||||
// A subscription is the SUBSCRIBER IDENTITY (contract rev 10). Either it hangs
|
||||
// off an account (`SubscriberUserID` set, the pre-rev-10 shape) or it stands
|
||||
// alone with a device token the service minted at join (`DeviceTokenHash` set,
|
||||
// `SubscriberUserID` is uuid.Nil). A subscriber is therefore never a fake user:
|
||||
// there is no auth.users row behind an anonymous one.
|
||||
type Subscription struct {
|
||||
ID uuid.UUID
|
||||
AlertID uuid.UUID
|
||||
@@ -35,15 +41,22 @@ type Subscription struct {
|
||||
Enabled bool
|
||||
LastSeenAt *time.Time
|
||||
Created time.Time
|
||||
// DeviceTokenHash is the SHA-256 of the token handed to the device once, at
|
||||
// join. Nil for account-backed subscriptions.
|
||||
DeviceTokenHash *string
|
||||
}
|
||||
|
||||
const subscriptionColumns = `id, alert_id, subscriber_user_id, device_id, device_label,
|
||||
platform, enabled, last_seen_at, created`
|
||||
platform, enabled, last_seen_at, created, device_token_hash`
|
||||
|
||||
func scanSubscription(row pgx.Row) (*Subscription, error) {
|
||||
var s Subscription
|
||||
err := row.Scan(&s.ID, &s.AlertID, &s.SubscriberUserID, &s.DeviceID, &s.DeviceLabel,
|
||||
&s.Platform, &s.Enabled, &s.LastSeenAt, &s.Created)
|
||||
var user *uuid.UUID
|
||||
err := row.Scan(&s.ID, &s.AlertID, &user, &s.DeviceID, &s.DeviceLabel,
|
||||
&s.Platform, &s.Enabled, &s.LastSeenAt, &s.Created, &s.DeviceTokenHash)
|
||||
if user != nil {
|
||||
s.SubscriberUserID = *user
|
||||
}
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
@@ -65,19 +78,24 @@ func CreateSubscription(ctx context.Context, s *Subscription) (*Subscription, er
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
var actor *uuid.UUID
|
||||
if s.SubscriberUserID != uuid.Nil {
|
||||
actor = &s.SubscriberUserID
|
||||
}
|
||||
|
||||
const q = `INSERT INTO alarm.subscriptions
|
||||
(alert_id, subscriber_user_id, device_id, device_label, platform)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
(alert_id, subscriber_user_id, device_id, device_label, platform, device_token_hash)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING ` + subscriptionColumns
|
||||
row, err := scanSubscription(tx.QueryRow(ctx, q,
|
||||
s.AlertID, s.SubscriberUserID, s.DeviceID, s.DeviceLabel, s.Platform))
|
||||
s.AlertID, actor, s.DeviceID, s.DeviceLabel, s.Platform, s.DeviceTokenHash))
|
||||
if isUniqueViolation(err) {
|
||||
return nil, ErrConflict
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := recordEvent(ctx, tx, s.AlertID, &s.SubscriberUserID, EventJoined, ""); err != nil {
|
||||
if err := recordEvent(ctx, tx, s.AlertID, actor, EventJoined, ""); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
@@ -94,6 +112,44 @@ func GetSubscriptionByDevice(ctx context.Context, alertID, userID uuid.UUID, dev
|
||||
return scanSubscription(Pool().QueryRow(ctx, q, alertID, userID, deviceID))
|
||||
}
|
||||
|
||||
// FindSubscriptionByDevice looks a subscription up by alert and device digest,
|
||||
// whichever path created it. It is the idempotency lookup for an anonymous
|
||||
// join, where there is no user id to key on.
|
||||
func FindSubscriptionByDevice(ctx context.Context, alertID uuid.UUID, deviceID string) (*Subscription, error) {
|
||||
const q = `SELECT ` + subscriptionColumns + `
|
||||
FROM alarm.subscriptions
|
||||
WHERE alert_id = $1 AND device_id = $2
|
||||
ORDER BY created
|
||||
LIMIT 1`
|
||||
return scanSubscription(Pool().QueryRow(ctx, q, alertID, deviceID))
|
||||
}
|
||||
|
||||
// GetSubscriptionByToken resolves the subscriber behind a device token. The
|
||||
// token itself is never stored: the caller hashes what it received and this
|
||||
// lookup runs against the unique index.
|
||||
func GetSubscriptionByToken(ctx context.Context, tokenHash string) (*Subscription, error) {
|
||||
const q = `SELECT ` + subscriptionColumns + `
|
||||
FROM alarm.subscriptions
|
||||
WHERE device_token_hash = $1`
|
||||
return scanSubscription(Pool().QueryRow(ctx, q, tokenHash))
|
||||
}
|
||||
|
||||
// RotateDeviceToken replaces the token hash of a subscription. A re-join from
|
||||
// the same device rotates rather than inserts, so a lost token is recoverable
|
||||
// with the subscribe token while the old one stops working immediately.
|
||||
func RotateDeviceToken(ctx context.Context, subscriptionID uuid.UUID, tokenHash string) error {
|
||||
tag, err := Pool().Exec(ctx,
|
||||
`UPDATE alarm.subscriptions SET device_token_hash = $2 WHERE id = $1`,
|
||||
subscriptionID, tokenHash)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSubscription fetches one subscription scoped to its alert.
|
||||
func GetSubscription(ctx context.Context, alertID, subscriptionID uuid.UUID) (*Subscription, error) {
|
||||
const q = `SELECT ` + subscriptionColumns + `
|
||||
@@ -113,12 +169,15 @@ func ListSubscriptions(ctx context.Context, alertID uuid.UUID) ([]Subscription,
|
||||
|
||||
out := make([]Subscription, 0, 4)
|
||||
for rows.Next() {
|
||||
var s Subscription
|
||||
if err := rows.Scan(&s.ID, &s.AlertID, &s.SubscriberUserID, &s.DeviceID, &s.DeviceLabel,
|
||||
&s.Platform, &s.Enabled, &s.LastSeenAt, &s.Created); err != nil {
|
||||
// Same scan as every other read: subscriber_user_id is NULL for a device
|
||||
// subscription (rev 10) and device_token_hash is part of the column set,
|
||||
// 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
|
||||
}
|
||||
out = append(out, s)
|
||||
out = append(out, *s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
import (
|
||||
@@ -5,9 +20,35 @@ import (
|
||||
"net/http"
|
||||
|
||||
"git.dcntrlized.org/Dcntrlized/Alarm/actions"
|
||||
"git.dcntrlized.org/Dcntrlized/Alarm/internal/apierr"
|
||||
"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.
|
||||
//
|
||||
// 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":
|
||||
actions.Me(w, r)
|
||||
default:
|
||||
http.Error(w, "Action not implemented", http.StatusNotImplemented)
|
||||
notImplemented(w)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -38,11 +79,8 @@ func ActionActionNameGet(w http.ResponseWriter, r *http.Request, params map[stri
|
||||
// ActionActionNamePost dispatches the POST /action/{actionName} request.
|
||||
func ActionActionNamePost(w http.ResponseWriter, r *http.Request, params map[string]string) {
|
||||
action := params["actionName"]
|
||||
var req types.ActionRequest
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
req, ok := decodeEnvelope(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -64,12 +102,12 @@ func ActionActionNamePost(w http.ResponseWriter, r *http.Request, params map[str
|
||||
actions.DisarmAlert(w, r, req.Payload)
|
||||
case "alarmStatus":
|
||||
actions.AlarmStatus(w, r, req.Payload)
|
||||
case "ackAlarm":
|
||||
actions.AckAlarm(w, r, req.Payload)
|
||||
case "deleteAlert":
|
||||
actions.DeleteAlert(w, r, req.Payload)
|
||||
case "qrImage":
|
||||
actions.QRImage(w, r, req.Payload)
|
||||
case "rotateSubscribeToken":
|
||||
actions.RotateSubscribeToken(w, r, req.Payload)
|
||||
|
||||
// Shared-auth proxies.
|
||||
case "login":
|
||||
@@ -83,7 +121,7 @@ func ActionActionNamePost(w http.ResponseWriter, r *http.Request, params map[str
|
||||
case "logout":
|
||||
actions.Logout(w, r, req.Payload)
|
||||
default:
|
||||
http.Error(w, "Action not implemented", http.StatusNotImplemented)
|
||||
notImplemented(w)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -91,17 +129,13 @@ func ActionActionNamePost(w http.ResponseWriter, r *http.Request, params map[str
|
||||
// ActionActionNamePut dispatches the PUT /action/{actionName} request.
|
||||
func ActionActionNamePut(w http.ResponseWriter, r *http.Request, params map[string]string) {
|
||||
action := params["actionName"]
|
||||
var req types.ActionRequest
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
if _, ok := decodeEnvelope(w, r); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
switch action {
|
||||
default:
|
||||
http.Error(w, "Action not implemented", http.StatusNotImplemented)
|
||||
notImplemented(w)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -111,11 +145,8 @@ func ActionActionNamePut(w http.ResponseWriter, r *http.Request, params map[stri
|
||||
// verb and carries the same {payload} envelope.
|
||||
func ActionActionNameDelete(w http.ResponseWriter, r *http.Request, params map[string]string) {
|
||||
action := params["actionName"]
|
||||
var req types.ActionRequest
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
req, ok := decodeEnvelope(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -125,7 +156,7 @@ func ActionActionNameDelete(w http.ResponseWriter, r *http.Request, params map[s
|
||||
case "removeSubscription":
|
||||
actions.RemoveSubscription(w, r, req.Payload)
|
||||
default:
|
||||
http.Error(w, "Action not implemented", http.StatusNotImplemented)
|
||||
notImplemented(w)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,18 @@ const (
|
||||
AlertAlreadyJoined = "ERR_ALERT_ALREADY_JOINED"
|
||||
AlertNotSubscribed = "ERR_ALERT_NOT_SUBSCRIBED"
|
||||
AlertState = "ERR_ALERT_STATE"
|
||||
Validation = "ERR_VALIDATION"
|
||||
Unauthorized = "ERR_UNAUTHORIZED"
|
||||
Internal = "ERR_INTERNAL"
|
||||
|
||||
// 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"
|
||||
Unauthorized = "ERR_UNAUTHORIZED"
|
||||
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"
|
||||
)
|
||||
|
||||
@@ -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[:])
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -157,7 +157,8 @@ func Warn(format string, v ...any) {
|
||||
func Error(format string, v ...any) {
|
||||
once.Do(initLogger)
|
||||
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.
|
||||
|
||||
@@ -17,11 +17,13 @@ package middleware
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.dcntrlized.org/Dcntrlized/Alarm/internal/loghash"
|
||||
"git.dcntrlized.org/Dcntrlized/Alarm/logger"
|
||||
)
|
||||
|
||||
@@ -49,19 +51,31 @@ func LoggingMiddleware(next http.Handler) http.Handler {
|
||||
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
|
||||
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),
|
||||
r.Method,
|
||||
r.Proto,
|
||||
r.URL.Path,
|
||||
rec.status,
|
||||
r.UserAgent(),
|
||||
ip,
|
||||
uaHash,
|
||||
ipHash,
|
||||
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.
|
||||
// It preserves http.Hijacker so the /ws/alarm upgrade still works when the
|
||||
// route is mounted behind this middleware.
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
package realtime
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -34,6 +36,8 @@ import (
|
||||
// ?alertId=<uuid> required
|
||||
// &token=<secret> required; the alert's admin token OR its subscribe token
|
||||
// &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
|
||||
// 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
|
||||
// token is only honoured for the user the alert was created by.
|
||||
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"))
|
||||
if access == "" {
|
||||
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])
|
||||
}
|
||||
|
||||
// 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[:])
|
||||
}
|
||||
|
||||
@@ -16,10 +16,13 @@
|
||||
package realtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.dcntrlized.org/Dcntrlized/Alarm/db"
|
||||
"github.com/google/uuid"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
@@ -107,6 +110,11 @@ func (c *client) readPump() {
|
||||
c.acks++
|
||||
c.ackMu.Unlock()
|
||||
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":
|
||||
c.touch()
|
||||
// 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
|
||||
// gorilla explicitly allows concurrently).
|
||||
func (c *client) writePump() {
|
||||
|
||||
+100
-28
@@ -43,7 +43,9 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.dcntrlized.org/Dcntrlized/Alarm/db"
|
||||
"git.dcntrlized.org/Dcntrlized/Alarm/internal/alertstate"
|
||||
"git.dcntrlized.org/Dcntrlized/Alarm/internal/apierr"
|
||||
"github.com/google/uuid"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
@@ -56,19 +58,21 @@ const (
|
||||
DefaultSendBuffer = 16
|
||||
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 = 256
|
||||
)
|
||||
|
||||
// AlarmFrame is the server -> client alarm frame.
|
||||
type AlarmFrame struct {
|
||||
Type string `json:"type"` // always "alarm"
|
||||
AlertID string `json:"alertId"`
|
||||
EventID string `json:"eventId"`
|
||||
Action string `json:"action"` // "trigger" | "disarm"
|
||||
Message string `json:"message"`
|
||||
ServerTime string `json:"serverTime"` // RFC3339, UTC
|
||||
AutoStopSeconds int `json:"autoStopSeconds"`
|
||||
Type string `json:"type"` // always "alarm"
|
||||
AlertID string `json:"alertId"`
|
||||
EventID string `json:"eventId"`
|
||||
Action string `json:"action"` // "trigger" | "disarm"
|
||||
Message string `json:"message"`
|
||||
ServerTime string `json:"serverTime"` // RFC3339, UTC
|
||||
}
|
||||
|
||||
// AckFrame is the server -> client acknowledgement of a client ack.
|
||||
@@ -87,9 +91,12 @@ type ClientFrame struct {
|
||||
|
||||
// Identity is the authenticated WebSocket caller.
|
||||
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
|
||||
IsAdmin bool
|
||||
IsDevice bool
|
||||
SubscriptionID *uuid.UUID
|
||||
DeviceID string
|
||||
}
|
||||
@@ -114,6 +121,10 @@ type Options struct {
|
||||
Authenticate func(r *http.Request) (*Identity, error)
|
||||
// Touch refreshes subscriptions.last_seen_at. Optional.
|
||||
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
|
||||
PongWait time.Duration
|
||||
@@ -142,22 +153,50 @@ func (o Options) withDefaults() Options {
|
||||
if o.Logf == nil {
|
||||
o.Logf = log.Printf
|
||||
}
|
||||
if o.PendingReplay == nil {
|
||||
o.PendingReplay = ledgerReplay
|
||||
}
|
||||
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 {
|
||||
frame AlarmFrame
|
||||
at time.Time
|
||||
}
|
||||
|
||||
func (e lastEvent) active(now time.Time) bool {
|
||||
if e.frame.Action != alertstate.KindTrigger {
|
||||
return false
|
||||
}
|
||||
if e.frame.AutoStopSeconds <= 0 {
|
||||
return true
|
||||
}
|
||||
return now.Before(e.at.Add(time.Duration(e.frame.AutoStopSeconds) * time.Second))
|
||||
// active reports whether the cached event is still live. With no auto-stop an
|
||||
// alarm is live exactly while it is a trigger that has not yet been superseded
|
||||
// by a disarm — and the cache only ever holds the newest event, so checking the
|
||||
// action is the whole test.
|
||||
func (e lastEvent) active() bool {
|
||||
return e.frame.Action == alertstate.KindTrigger
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if r.Method != 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
|
||||
}
|
||||
|
||||
@@ -215,7 +254,7 @@ func (h *Hub) Handler() http.HandlerFunc {
|
||||
writeJSONError(w, authErr.Status, authErr.Code, authErr.Message)
|
||||
return
|
||||
}
|
||||
writeJSONError(w, http.StatusUnauthorized, "ERR_UNAUTHORIZED", "unauthorized")
|
||||
writeJSONError(w, http.StatusUnauthorized, apierr.Unauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -223,13 +262,13 @@ func (h *Hub) Handler() http.HandlerFunc {
|
||||
closed := h.closed
|
||||
h.mu.Unlock()
|
||||
if closed {
|
||||
writeJSONError(w, http.StatusServiceUnavailable, "ERR_UNAVAILABLE", "server is shutting down")
|
||||
writeJSONError(w, http.StatusServiceUnavailable, apierr.Unavailable, "server is shutting down")
|
||||
return
|
||||
}
|
||||
|
||||
conn, err := h.upgrader.Upgrade(w, r, 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)
|
||||
return
|
||||
}
|
||||
@@ -237,10 +276,26 @@ func (h *Hub) Handler() http.HandlerFunc {
|
||||
c := newClient(h, conn, *identity)
|
||||
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
|
||||
// live, so a client that reconnects mid-alarm fires immediately and a
|
||||
// client reconnecting after the alarm is never re-alarmed.
|
||||
if f, ok := h.lastActive(identity.AlertID); ok {
|
||||
// client reconnecting after the alarm is never re-alarmed. Skipped when
|
||||
// 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 {
|
||||
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) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
@@ -344,7 +417,7 @@ func (h *Hub) lastActive(alertID uuid.UUID) (AlarmFrame, bool) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
e, ok := h.last[alertID]
|
||||
if !ok || !e.active(time.Now().UTC()) {
|
||||
if !ok || !e.active() {
|
||||
return AlarmFrame{}, false
|
||||
}
|
||||
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) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"error": map[string]string{"code": code, "message": message},
|
||||
})
|
||||
apierr.Write(w, status, code, message)
|
||||
}
|
||||
|
||||
+133
-8
@@ -21,9 +21,11 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.dcntrlized.org/Dcntrlized/Alarm/internal/apierr"
|
||||
"github.com/google/uuid"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
@@ -191,13 +193,12 @@ func TestReconnectReplaysLiveTrigger(t *testing.T) {
|
||||
|
||||
// Broadcast with nobody connected: the hub keeps it as the last event.
|
||||
frame := AlarmFrame{
|
||||
Type: "alarm",
|
||||
AlertID: alertID.String(),
|
||||
EventID: uuid.NewString(),
|
||||
Action: "trigger",
|
||||
Message: "still ringing",
|
||||
ServerTime: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
AutoStopSeconds: 300,
|
||||
Type: "alarm",
|
||||
AlertID: alertID.String(),
|
||||
EventID: uuid.NewString(),
|
||||
Action: "trigger",
|
||||
Message: "still ringing",
|
||||
ServerTime: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
}
|
||||
if delivered := hub.Broadcast(alertID, frame); delivered != 0 {
|
||||
t.Fatalf("Broadcast with no clients delivered to %d, want 0", delivered)
|
||||
@@ -226,7 +227,8 @@ func TestHandlerRejectsNonGet(t *testing.T) {
|
||||
}
|
||||
var body struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
@@ -235,4 +237,127 @@ func TestHandlerRejectsNonGet(t *testing.T) {
|
||||
if body.Error.Code == "" {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,13 +44,12 @@ func TestBroadcastLatency(t *testing.T) {
|
||||
for i := 0; i < iterations; i++ {
|
||||
eventID := uuid.NewString()
|
||||
frame := AlarmFrame{
|
||||
Type: "alarm",
|
||||
AlertID: alertID.String(),
|
||||
EventID: eventID,
|
||||
Action: "trigger",
|
||||
Message: "latency probe",
|
||||
ServerTime: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
AutoStopSeconds: 300,
|
||||
Type: "alarm",
|
||||
AlertID: alertID.String(),
|
||||
EventID: eventID,
|
||||
Action: "trigger",
|
||||
Message: "latency probe",
|
||||
ServerTime: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
}
|
||||
start := time.Now()
|
||||
if delivered := hub.Broadcast(alertID, frame); delivered != 2 {
|
||||
|
||||
+12
-14
@@ -36,26 +36,24 @@ const NotifyChannel = "alarm_events"
|
||||
// the one that served the trigger request — fans the event out to its own
|
||||
// subscribers.
|
||||
type NotifyPayload struct {
|
||||
AlertID string `json:"alertId"`
|
||||
EventID string `json:"eventId"`
|
||||
Action string `json:"action"`
|
||||
Message string `json:"message"`
|
||||
AutoStopSeconds int `json:"autoStopSeconds"`
|
||||
CreatedAtMs int64 `json:"createdAtMs"`
|
||||
AlertID string `json:"alertId"`
|
||||
EventID string `json:"eventId"`
|
||||
Action string `json:"action"`
|
||||
Message string `json:"message"`
|
||||
CreatedAtMs int64 `json:"createdAtMs"`
|
||||
}
|
||||
|
||||
// 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
|
||||
// on when the alarm started (and therefore on the auto-stop countdown).
|
||||
// on when the alarm started.
|
||||
func (p NotifyPayload) Frame() AlarmFrame {
|
||||
return AlarmFrame{
|
||||
Type: "alarm",
|
||||
AlertID: p.AlertID,
|
||||
EventID: p.EventID,
|
||||
Action: p.Action,
|
||||
Message: p.Message,
|
||||
ServerTime: time.UnixMilli(p.CreatedAtMs).UTC().Format(time.RFC3339Nano),
|
||||
AutoStopSeconds: p.AutoStopSeconds,
|
||||
Type: "alarm",
|
||||
AlertID: p.AlertID,
|
||||
EventID: p.EventID,
|
||||
Action: p.Action,
|
||||
Message: p.Message,
|
||||
ServerTime: time.UnixMilli(p.CreatedAtMs).UTC().Format(time.RFC3339Nano),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user