This commit is contained in:
2026-10-18 22:31:16 +01:00
commit ec38e680d6
64 changed files with 7222 additions and 0 deletions
+744
View File
@@ -0,0 +1,744 @@
# 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.