Files
Alarm/API.md
T
2026-10-18 22:31:16 +01:00

745 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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).
## 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
*rotatable* and 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_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.
## 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 |
| `alarmStatus` | GET, POST | — | admin **or** subscriber |
| `deleteAlert` | DELETE, POST | required | admin |
| `qrImage` | GET, POST | — | admin **or** subscriber |
| `rotateSubscribeToken` | POST | required | admin |
### 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",
"autoStopSeconds": 300
} }
```
| Field | Type | Rules |
| --- | --- | --- |
| `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`:
```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#…",
"autoStopSeconds": 300,
"tokenVersion": 1,
"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.
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
"autoStopSeconds": 300,
"tokenVersion": 1,
"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 UUID persisted in app storage is enough) and never
rotate 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.
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": "…", "deviceId": "android-8f2c…",
"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.
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",
"autoStopSeconds": 300,
"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`**, restarting the auto-stop window. Clients must treat each
`eventId` as a new alarm.
- 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",
"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.
Disarming an alert that is **not** currently active is `409 ERR_ALERT_STATE`.
Errors: as `triggerAlert`, plus `ERR_ALERT_STATE`.
## `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": "…" } }`
```json
{
"active": true,
"eventId": "…",
"action": "trigger",
"message": "Leave the building now",
"serverTime": "2026-10-18T17:05:00.123Z",
"autoStopSeconds": 300
}
```
`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`.
`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.
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 and its event history (cascade). 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).
## `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:
```
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`
```
ws://<host>:8088/ws/alarm?alertId=<uuid>&token=<adminToken|subscribeToken>&accessToken=<token>[&deviceId=<id>]
```
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",
"autoStopSeconds": 300
}
```
`ack` — the server's acknowledgement of a client ack:
```json
{ "type": "ack", "eventId": "…" }
```
### 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
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.
### 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
const ws = new WebSocket(
`ws://alarm:8088/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
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.
---
## `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
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","autoStopSeconds":300}}' | 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
```
## 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 + version, status, timestamps, `auto_stop_seconds` |
| `alarm.subscriptions` | `(alert, user, device)` unique, label/platform, `enabled`, `last_seen_at` |
| `alarm.alarm_events` | append-only trigger/disarm history; its `AFTER INSERT` trigger issues the `pg_notify` that fans out to other instances |
Migrations live in `database/migrations/` and are applied by the compose
`migrate` one-shot; they are idempotent and safe to re-apply.