Document the wire contract as implemented: single-origin access through Maestro, no auto-stop, the owner-set persistence cap, the durable delivery ledger and its 409 check-in, explicit acks, hashed device ids, one anonymised event log, and the four ways an alert ends (delivered, expired, disarmed, deleted). Also corrects the heading to match the table it introduces.
1050 lines
46 KiB
Markdown
1050 lines
46 KiB
Markdown
# Alarm — service API (v1, frozen)
|
||
|
||
The Alarm service is the server half of the Dcntrlized alert feature: one user
|
||
(the **admin**) creates an alert and holds an admin token; everybody else joins
|
||
by scanning the alert's **subscribe token** as a QR code; the admin can trigger
|
||
and disarm the alert at will, and a trigger must fire **live** on every
|
||
subscribed client.
|
||
|
||
- Container / DNS name: `alarm`
|
||
- One plaintext listener inside the container: `:8080` (dev host port `8088`)
|
||
- Module: `git.dcntrlized.org/Dcntrlized/Alarm`
|
||
- Image: `192.168.122.183:3000/dcntrlized/alarm:dev`
|
||
|
||
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 |
|
||
| --- | --- |
|
||
| `GET /_manifest` | service manifest consumed by Maestro |
|
||
| `/auth/*` | shared-auth proxy (`dcntrlized-auth`), unchanged from every other service |
|
||
| `/action/{actionName}` | the action API documented below |
|
||
| `GET /ws/alarm` | the live alarm socket |
|
||
|
||
`/ws/alarm` is deliberately registered on the **outer** mux, outside the
|
||
logging/CORS chain: the connection is hijacked and the subscribe token travels
|
||
in the query string, so it must never reach a request logger.
|
||
|
||
## Envelope and authentication
|
||
|
||
`/action/*` uses the platform envelope:
|
||
|
||
```json
|
||
{ "payload": { "...": "action specific" } }
|
||
```
|
||
|
||
- `POST`, `PUT` and `DELETE` read the envelope from the body. Unknown fields
|
||
**inside `payload` are ignored** (forward compatible); an unknown top-level
|
||
envelope field is rejected by the dispatcher with a plain-text `400` (see
|
||
*Errors* below).
|
||
- `GET` carries no body: the same actions take their arguments from the query
|
||
string, e.g. `GET /action/getAlert?alertId=…`.
|
||
- Every action that addresses an alert accepts **`alertId` under the alias
|
||
`id`**, both as a query parameter and as a payload key:
|
||
`?id=<uuid>` and `{ "payload": { "id": "<uuid>" } }` work exactly like
|
||
`alertId`. Precedence is query `alertId` → query `id` → payload `alertId` →
|
||
payload `id`; the first non-empty one wins.
|
||
|
||
**Every alert action requires a verified access token** in the `X-Access-Token`
|
||
header (minted by `dcntrlized-auth`). A missing, malformed or expired token is
|
||
always:
|
||
|
||
```json
|
||
{ "error": { "code": "ERR_UNAUTHORIZED", "message": "…" } }
|
||
```
|
||
|
||
with HTTP `401`. The service never trusts a user id, name or email from the
|
||
payload — identity comes from the token only. The shared-auth proxies
|
||
(`login`, `register`, `refresh`, `delete`, `logout`, `me`) are the exception:
|
||
`login`/`register` mint the token, `me`/`logout`/`refresh`/`delete` forward
|
||
whatever header the caller sent and let `dcntrlized-auth` decide.
|
||
|
||
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 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.
|
||
|
||
**Action-level errors** — everything the action handlers themselves return —
|
||
use the platform envelope:
|
||
|
||
```json
|
||
{ "error": { "code": "ERR_…", "message": "human readable" } }
|
||
```
|
||
|
||
That covers every code in the table below (401/403/404/409/500) as well as the
|
||
WebSocket rejections.
|
||
|
||
**Dispatch-level errors** are the exception. They are produced by the layer
|
||
that routes `/action/{actionName}` by HTTP method, *before* any handler or
|
||
error helper runs, and they are **plain text** (`text/plain; charset=utf-8`),
|
||
never JSON. A client that calls `JSON.parse` on every error body throws on
|
||
exactly these — check `Content-Type` first, and treat a non-JSON body as a
|
||
generic failure:
|
||
|
||
| Situation | HTTP | Body | Example |
|
||
| --- | --- | --- | --- |
|
||
| unknown action name for this verb | `501` | `Action not implemented` | `POST /action/getAlerts` (typo), `GET /action/triggerAlert`, **any** `PUT /action/<name>` |
|
||
| malformed request envelope | `400` | the JSON decoder's message | body is not valid JSON, has an unknown **top-level** field (`DisallowUnknownFields`), or cannot be decoded into the envelope — e.g. `json: unknown field "payloads"` |
|
||
| unsupported HTTP verb | `405` | `method not allowed` | `PATCH /action/createAlert` |
|
||
|
||
Consequences worth internalising:
|
||
|
||
- `PUT` is routed but implements nothing, so **every** `PUT /action/<name>` is
|
||
`501` — including for actions that exist under another verb.
|
||
- A known action sent with the *wrong* verb is also `501`, not `405`. `405` is
|
||
reserved for a verb outside `GET`/`POST`/`PUT`/`DELETE`.
|
||
- `GET` never decodes a body, so it can only produce the `501` row.
|
||
- Only `POST`, `PUT` and `DELETE` can produce the plain-text `400`.
|
||
|
||
## Error codes
|
||
|
||
| Code | HTTP | Meaning |
|
||
| --- | --- | --- |
|
||
| `ERR_VALIDATION` | 400 | malformed payload, bad uuid, field too long, bad query parameter |
|
||
| `ERR_UNAUTHORIZED` | 401 | missing/invalid access token |
|
||
| `ERR_ALERT_TOKEN_INVALID` | 403 | the `adminToken` or `subscribeToken` does not match this alert |
|
||
| `ERR_ALERT_NOT_ADMIN` | 403 | a valid access token, but not the alert's admin, for an admin-only action |
|
||
| `ERR_ALERT_NOT_FOUND` | 404 | no such alert (also returned instead of 403 where hiding existence matters) |
|
||
| `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
|
||
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
|
||
case the action name is the path segment after `/action/`.
|
||
|
||
`POST` accepts every **alert** action plus the shared-auth proxies. `GET`
|
||
accepts the read-only alert subset plus `me` and `logout` — **`me` is
|
||
GET-only** (`POST /action/me` is a `501`). `DELETE` accepts the two removals.
|
||
`PUT` implements nothing at all.
|
||
|
||
### Alert actions
|
||
|
||
| Action | Verbs | Admin token | Access |
|
||
| --- | --- | --- | --- |
|
||
| `createAlert` | POST | — | any authenticated user becomes the admin |
|
||
| `getAlert` | GET, POST | — | admin **or** subscriber |
|
||
| `joinAlert` | POST | — | any authenticated user holding the subscribe token |
|
||
| `listSubscriptions` | GET, POST | — | alert admin only: the access-token user must be the alert's `adminUserId`; **no** admin token is used |
|
||
| `removeSubscription` | DELETE, POST | — | admin (any row) or the subscriber (own row) |
|
||
| `triggerAlert` | POST | required | admin |
|
||
| `disarmAlert` | POST | required | admin |
|
||
| `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 |
|
||
|
||
### Shared-auth actions (`/auth/*` proxies)
|
||
|
||
Thin proxies to `dcntrlized-auth`, kept on `/action/` for the RN clients that
|
||
send everything through one path. They take the same `{payload}` envelope; the
|
||
payload is forwarded as the raw auth body and the auth response is relayed
|
||
as-is, unwrapped (so these return the auth service's own body shape, not the
|
||
alert shapes documented here).
|
||
|
||
| Action | Verbs | Proxies | Payload |
|
||
| --- | --- | --- | --- |
|
||
| `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": "…" }` |
|
||
| `register` | POST | `POST /auth/register` | `{ "username": "…", "password": "…", "email": "…" }` |
|
||
| `refresh` | POST | `POST /auth/refresh` | `{ "refreshToken": "…" }` |
|
||
| `delete` | POST | `POST /auth/delete` | `{ "password": "…" }` |
|
||
|
||
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.
|
||
|
||
---
|
||
|
||
## `createAlert`
|
||
|
||
Creates an alert and makes the caller its admin.
|
||
|
||
**POST** `/action/createAlert`
|
||
|
||
```json
|
||
{ "payload": {
|
||
"name": "Fire drill",
|
||
"message": "Leave the building now"
|
||
} }
|
||
```
|
||
|
||
| Field | Type | Rules |
|
||
| --- | --- | --- |
|
||
| `name` | string | required, 1–255 characters after trimming |
|
||
| `message` | string | optional, ≤ 2000 bytes, the default text pushed with a trigger |
|
||
|
||
Response `200`:
|
||
|
||
```json
|
||
{
|
||
"alertId": "0f8fad5b-d9cb-469f-a165-70867728950e",
|
||
"name": "Fire drill",
|
||
"message": "Leave the building now",
|
||
"status": "armed",
|
||
"active": false,
|
||
"adminToken": "…", // shown ONCE, store it
|
||
"subscribeToken": "…",
|
||
"qrPayload": "https://alarm.dcntrlized.org/a/0f8f…e#…",
|
||
"created": "2026-10-18T17:00:00Z"
|
||
}
|
||
```
|
||
|
||
`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`
|
||
|
||
Returns the alert, the caller's relationship to it, and the newest event.
|
||
|
||
**GET** `/action/getAlert?alertId=<uuid>` · **POST** `{ "payload": { "alertId": "…" } }`
|
||
|
||
Response `200`:
|
||
|
||
```json
|
||
{
|
||
"alertId": "…",
|
||
"name": "Fire drill",
|
||
"message": "Leave the building now",
|
||
"status": "triggered", // "armed" | "triggered" | "disarmed"
|
||
"active": true, // derived, see alarmStatus
|
||
"created": "2026-10-18T17:00:00Z",
|
||
"modified": "2026-10-18T17:05:00Z",
|
||
"triggeredAt": "2026-10-18T17:05:00Z",
|
||
"qrPayload": "https://alarm.dcntrlized.org/a/…#…",
|
||
"isAdmin": true,
|
||
"subscriptionCount": 2,
|
||
"lastEvent": {
|
||
"eventId": "…", "action": "trigger", "message": "Leave the building now",
|
||
"serverTime": "2026-10-18T17:05:00Z"
|
||
}
|
||
}
|
||
```
|
||
|
||
`qrPayload` is present for the admin and for subscribers (the subscriber already
|
||
holds that token). `isAdmin` tells the client which controls to render.
|
||
|
||
Optional fields are **omitted, never sent as `null`** (`omitempty` on the
|
||
wire). That covers `triggeredAt` (absent until the first trigger — and *kept*
|
||
after a disarm, so it always means "when it last rang"), `disarmedAt` (absent
|
||
until a disarm) and `lastEvent` (absent while the alert has no events). Test for
|
||
presence, not for `null`.
|
||
|
||
Errors: `ERR_VALIDATION`, `ERR_ALERT_NOT_FOUND`, `ERR_ALERT_NOT_SUBSCRIBED`,
|
||
`ERR_UNAUTHORIZED`.
|
||
|
||
## `joinAlert`
|
||
|
||
Subscribes the caller's device. This is what the QR scan calls.
|
||
|
||
**POST** `/action/joinAlert`
|
||
|
||
```json
|
||
{ "payload": {
|
||
"alertId": "0f8fad5b-…",
|
||
"subscribeToken": "…",
|
||
"deviceId": "android-8f2c…",
|
||
"deviceLabel": "Pixel 7",
|
||
"platform": "android"
|
||
} }
|
||
```
|
||
|
||
| Field | Type | Rules |
|
||
| --- | --- | --- |
|
||
| `alertId` | uuid | required (the scanner takes it from the QR payload) |
|
||
| `subscribeToken` | string | required, 16–64 url-safe characters, must match the alert |
|
||
| `deviceId` | string | **required**, 1–128 characters after trimming, no control characters — the client generates and persists a stable per-install id and sends it on every join |
|
||
| `deviceLabel` | string | optional, ≤ 128 chars |
|
||
| `platform` | string | optional, ≤ 32 chars (`android`, `ios`, …) |
|
||
|
||
`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 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
|
||
same device returns `200` with the same subscription id and creates no new row.
|
||
The same user scanning on a *second* device creates a second subscription.
|
||
`ERR_ALERT_ALREADY_JOINED` is only the 409 fallback for a concurrent duplicate
|
||
insert race.
|
||
|
||
Errors: `ERR_VALIDATION`, `ERR_ALERT_NOT_FOUND`, `ERR_ALERT_TOKEN_INVALID`,
|
||
`ERR_ALERT_ALREADY_JOINED`, `ERR_UNAUTHORIZED`.
|
||
|
||
## `listSubscriptions`
|
||
|
||
**GET** `/action/listSubscriptions?alertId=<uuid>` ·
|
||
**POST** `{ "payload": { "alertId": "…" } }`
|
||
|
||
Admin only (the access token must belong to the alert's admin — the admin token
|
||
is *not* required for this read).
|
||
|
||
```json
|
||
{ "items": [
|
||
{ "id": "…", "userId": "…",
|
||
"deviceLabel": "Pixel 7", "platform": "android",
|
||
"enabled": true, "lastSeenAt": "2026-10-18T17:04:00Z",
|
||
"created": "2026-10-18T16:00:00Z" }
|
||
] }
|
||
```
|
||
|
||
`username` is intentionally **never** populated: it lives in the auth service's
|
||
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`.
|
||
|
||
## `removeSubscription`
|
||
|
||
**DELETE** `/action/removeSubscription` · **POST** (same envelope)
|
||
|
||
```json
|
||
{ "payload": { "alertId": "…", "subscriptionId": "…" } }
|
||
```
|
||
|
||
The admin may remove any subscription of the alert; a subscriber may remove its
|
||
own. Response `200`:
|
||
|
||
```json
|
||
{ "alertId": "…", "subscriptionId": "…", "removed": true }
|
||
```
|
||
|
||
Errors: `ERR_VALIDATION`, `ERR_ALERT_NOT_FOUND`, `ERR_ALERT_NOT_SUBSCRIBED`,
|
||
`ERR_ALERT_NOT_ADMIN`, `ERR_UNAUTHORIZED`.
|
||
|
||
## `triggerAlert`
|
||
|
||
Fires the alarm **live** on every subscriber of every instance.
|
||
|
||
**POST** `/action/triggerAlert`
|
||
|
||
```json
|
||
{ "payload": {
|
||
"alertId": "0f8fad5b-…",
|
||
"adminToken": "…",
|
||
"message": "Optional per-trigger override"
|
||
} }
|
||
```
|
||
|
||
`message` is optional; when omitted the alert's stored message is used. The
|
||
override is **not** persisted — it applies to this event only.
|
||
|
||
Response `200`:
|
||
|
||
```json
|
||
{
|
||
"eventId": "…",
|
||
"action": "trigger",
|
||
"serverTime": "2026-10-18T17:05:00.123Z",
|
||
"delivered": 2
|
||
}
|
||
```
|
||
|
||
- `delivered` is the number of **live WebSocket clients on this instance** that
|
||
the frame was written to. It is not a delivery guarantee: clients that were
|
||
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`**. 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.
|
||
|
||
Errors: `ERR_VALIDATION`, `ERR_ALERT_NOT_FOUND`, `ERR_ALERT_TOKEN_INVALID`,
|
||
`ERR_ALERT_NOT_ADMIN`, `ERR_UNAUTHORIZED`.
|
||
|
||
## `disarmAlert`
|
||
|
||
**POST** `/action/disarmAlert` — same payload as `triggerAlert` (the `message`
|
||
override applies to the disarm event the same way).
|
||
|
||
```json
|
||
{ "payload": { "alertId": "…", "adminToken": "…", "message": "All clear" } }
|
||
```
|
||
|
||
Response `200` — same shape as `triggerAlert` with `"action": "disarm"`:
|
||
|
||
```json
|
||
{
|
||
"eventId": "…",
|
||
"action": "disarm",
|
||
"serverTime": "2026-10-18T17:09:00.456Z",
|
||
"delivered": 2
|
||
}
|
||
```
|
||
|
||
Disarm **ends the alert**, whatever its 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.
|
||
|
||
**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>[&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
|
||
{
|
||
"active": true,
|
||
"eventId": "…",
|
||
"action": "trigger",
|
||
"message": "Leave the building now",
|
||
"serverTime": "2026-10-18T17:05:00.123Z"
|
||
}
|
||
```
|
||
|
||
`active` is **derived**, never a ringing timer: it is true only when the newest
|
||
event is a `trigger` and the stored status is `triggered`. The answer stays
|
||
correct across restarts and instances, and it stays `true` until the owner
|
||
disarms or the alert expires. (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 acknowledged by the act of returning this response, so the
|
||
same uninterrupted alarm cannot produce a second `409`.
|
||
- On the live path the WebSocket `ack` frame marks them too, 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 ledger entries are cleared and 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" } }
|
||
```
|
||
|
||
Response `200`:
|
||
|
||
```json
|
||
{ "alertId": "…", "eventId": "…", "acked": 1, "destroyed": false }
|
||
```
|
||
|
||
- 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.
|
||
- Idempotent: a repeat ack (or an ack for an event the device never owed) returns
|
||
`acked: 0`, never an error. The WebSocket `ack` frame settles the same rows.
|
||
- `acked` counts the ledger rows cleared for this device. When the ack clears the
|
||
last unacked row of the alert, the alert drains and is destroyed (reason
|
||
`delivered`) — `destroyed: true` reports that intended outcome.
|
||
- 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`.
|
||
|
||
## `deleteAlert`
|
||
|
||
**DELETE** `/action/deleteAlert` · **POST** (same envelope)
|
||
|
||
```json
|
||
{ "payload": { "alertId": "…", "adminToken": "…" } }
|
||
```
|
||
|
||
Deletes the alert, its subscriptions, its event history and its delivery-ledger
|
||
rows (all `ON DELETE CASCADE` in the schema). Response
|
||
`200`:
|
||
|
||
```json
|
||
{ "alertId": "…", "deleted": true }
|
||
```
|
||
|
||
Refused with `409 ERR_ALERT_STATE` while the alert is active — disarm first, so
|
||
a ringing alarm cannot be deleted out from under its subscribers.
|
||
|
||
Errors: `ERR_VALIDATION`, `ERR_ALERT_NOT_FOUND`, `ERR_ALERT_TOKEN_INVALID`,
|
||
`ERR_ALERT_NOT_ADMIN`, `ERR_ALERT_STATE`, `ERR_UNAUTHORIZED`.
|
||
|
||
## `qrImage`
|
||
|
||
Renders the alert's subscribe QR as a PNG.
|
||
|
||
**GET** `/action/qrImage?alertId=<uuid>&scale=8&quietZone=4` ·
|
||
**POST** `{ "payload": { "alertId": "…" } }`
|
||
|
||
| Query | Default | Range |
|
||
| --- | --- | --- |
|
||
| `scale` | `8` | 1–64 pixels per module |
|
||
| `quietZone` (alias `margin`) | `4` | 4–32 modules |
|
||
|
||
A quiet zone below **4 modules** is rejected with `400 ERR_VALIDATION`: the
|
||
ISO/IEC 18004 minimum is 4 and smaller margins produce symbols real scanners
|
||
refuse (measured: `zbarimg` returns "no symbol" at 0–3).
|
||
|
||
Response `200` is **binary**, not JSON: `Content-Type: image/png`,
|
||
`Cache-Control: no-store`, `X-Content-Type-Options: nosniff`. The image encodes
|
||
exactly `qrPayload` (below). The QR is byte-mode, EC level **M**, version
|
||
chosen automatically from the payload length.
|
||
|
||
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).
|
||
|
||
## QR payload format
|
||
|
||
The QR encodes exactly the `qrPayload` field:
|
||
|
||
```
|
||
https://alarm.dcntrlized.org/a/<alertId>#<subscribeToken>
|
||
```
|
||
|
||
An app-scheme deep link carries the same pair for in-app scanners:
|
||
|
||
```
|
||
dcntrlized-alarm://join?alert=<alertId>&token=<subscribeToken>
|
||
```
|
||
|
||
`alertId` is a UUID and `subscribeToken` is 16–64 url-safe characters
|
||
(`A–Z a–z 0–9 - _`). The fragment is used because a URL fragment is never sent
|
||
to a server by a browser — the token stays client-side until the app posts it to
|
||
`joinAlert`.
|
||
|
||
---
|
||
|
||
## Live protocol — `GET /ws/alarm`
|
||
|
||
The service path is:
|
||
|
||
```
|
||
/ws/alarm?alertId=<uuid>&token=<adminToken|subscribeToken>&accessToken=<token>[&deviceId=<id>]
|
||
```
|
||
|
||
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**: the access token identifies the
|
||
user and the second token proves the relationship to the alert.
|
||
|
||
### Authentication
|
||
|
||
| Credential | Where | Required |
|
||
| --- | --- | --- |
|
||
| access token (who you are) | `X-Access-Token` header **or** `?accessToken=` / `?access_token=` | yes |
|
||
| `alertId` | query | yes, valid uuid |
|
||
| `token` | query | yes — the admin token **or** the subscribe token |
|
||
| `deviceId` | query | optional; the `deviceId` used at `joinAlert` — selects which subscription's `last_seen_at` is refreshed (without it the oldest subscription is used) |
|
||
|
||
- admin token match **and** the caller is the alert's admin → accepted as the
|
||
admin connection (no subscription required);
|
||
- subscribe token match **and** the caller has a subscription on that alert →
|
||
accepted as a subscriber connection (`?deviceId=` selects the subscription,
|
||
otherwise the oldest one is used);
|
||
- anything else is rejected **before the upgrade** with the usual JSON error
|
||
(`401 ERR_UNAUTHORIZED`, `403 ERR_ALERT_TOKEN_INVALID`,
|
||
`403 ERR_ALERT_NOT_ADMIN`, `404 ERR_ALERT_NOT_SUBSCRIBED`,
|
||
`404 ERR_ALERT_NOT_FOUND`, or `503 ERR_UNAVAILABLE` during shutdown).
|
||
A non-`GET` request gets `405 ERR_METHOD_NOT_ALLOWED`.
|
||
|
||
A header is preferred for the access token (headers do not appear in access
|
||
logs); the query form exists because browsers cannot set headers on a WebSocket
|
||
handshake.
|
||
|
||
### Server → client frames
|
||
|
||
`alarm` — the alarm event. Every subscribed client receives the **same frame**
|
||
from one fan-out:
|
||
|
||
```json
|
||
{
|
||
"type": "alarm",
|
||
"alertId": "0f8fad5b-…",
|
||
"eventId": "…",
|
||
"action": "trigger", // "trigger" | "disarm"
|
||
"message": "Leave the building now",
|
||
"serverTime": "2026-10-18T17:05:00.123Z"
|
||
}
|
||
```
|
||
|
||
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
|
||
|
||
`ack` — "I have this event and I am ringing":
|
||
|
||
```json
|
||
{ "type": "ack", "eventId": "…" }
|
||
```
|
||
|
||
`ping` — application-level keepalive. The server answers with a WebSocket
|
||
**pong control frame** (there is no JSON pong) and refreshes the subscription's
|
||
`last_seen_at`.
|
||
|
||
```json
|
||
{ "type": "ping" }
|
||
```
|
||
|
||
Unknown or malformed client frames are ignored, never fatal.
|
||
|
||
**The one fatal exception is frame size.** A client frame larger than **4096
|
||
bytes** is a protocol violation: the server refuses it and closes the
|
||
connection with the WebSocket close code `1009` (message too big). `ack` and
|
||
`ping` are tiny, so this only bites a client that tries to send something else
|
||
through the socket — don't. If you see an unexplained disconnect, this is a
|
||
candidate; the same applies to sending non-JSON frames.
|
||
|
||
### Keepalive and timeouts
|
||
|
||
- The server sends a WebSocket ping every **20s**; a client that sends nothing
|
||
(no pong, no `ping`) for **60s** is dropped.
|
||
- Every write has a **10s** deadline, so a dead peer can never block the hub.
|
||
- Each connection has a buffered send queue. A client that cannot keep up is
|
||
**dropped** (its socket closed) rather than allowed to slow the fan-out down
|
||
for everyone else. Reconnect and the catch-up frame restores the state.
|
||
|
||
### Reconnect catch-up
|
||
|
||
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
|
||
|
||
Fan-out is local: `Broadcast` writes to this instance's connections. Every
|
||
trigger/disarm is also an `INSERT` into `alarm.alarm_events`, whose trigger
|
||
issues `pg_notify('alarm_events', …)`; each instance holds a `LISTEN` and
|
||
re-broadcasts to its own clients. So a trigger raised on instance A fires on
|
||
subscribers of instance B as well. Duplicate deliveries are suppressed by
|
||
`eventId`, so the local fast path and the NOTIFY path cannot double-fire.
|
||
|
||
### Minimal client
|
||
|
||
```js
|
||
// Through an instance: wss://<instance>/api/alarm/ws/alarm?…
|
||
const ws = new WebSocket(
|
||
`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") {
|
||
// 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);
|
||
};
|
||
setInterval(() => ws.readyState === 1 && ws.send('{"type":"ping"}'), 25000);
|
||
```
|
||
|
||
### Polling fallback
|
||
|
||
When the socket cannot be established, poll `alarmStatus` (every 5–15s while
|
||
the app is foregrounded) and treat `active: true` with a new `eventId` exactly
|
||
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`
|
||
|
||
```json
|
||
{
|
||
"name": "Alarm",
|
||
"author": "Dcntrlized",
|
||
"capabilities": ["database", "alarm.live"],
|
||
"has_auth": true,
|
||
"public_key": "",
|
||
"icon": ""
|
||
}
|
||
```
|
||
|
||
Served verbatim, `Content-Type: application/json`. `database` declares the
|
||
schema/migration capability Maestro provisions; `has_auth` tells Maestro to
|
||
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)
|
||
TOKEN=$(curl -s -X POST "$BASE/auth/login" \
|
||
-H 'Content-Type: application/json' \
|
||
-d '{"username":"admin","password":"admin_password_123"}' | jq -r .accessToken)
|
||
|
||
# 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"}}' | jq
|
||
|
||
# 3. a second user scans the QR payload and joins
|
||
curl -s -X POST "$BASE/action/joinAlert" -H "X-Access-Token: $BOB" \
|
||
-H 'Content-Type: application/json' \
|
||
-d '{"payload":{"alertId":"'$ALERT'","subscribeToken":"'$SUB'","deviceId":"pixel-7"}}' | jq
|
||
|
||
# 4. bob opens the socket (leave it running in another terminal)
|
||
websocat "ws://localhost:8088/ws/alarm?alertId=$ALERT&token=$SUB&accessToken=$BOB&deviceId=pixel-7"
|
||
|
||
# 5. trigger — bob's socket prints the alarm frame
|
||
curl -s -X POST "$BASE/action/triggerAlert" -H "X-Access-Token: $TOKEN" \
|
||
-H 'Content-Type: application/json' \
|
||
-d '{"payload":{"alertId":"'$ALERT'","adminToken":"'$ADMIN'"}}' | jq
|
||
|
||
# 6. disarm
|
||
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
|
||
|
||
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, 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.
|