27 KiB
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 port8088) - 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:
{ "payload": { "...": "action specific" } }
POST,PUTandDELETEread the envelope from the body. Unknown fields insidepayloadare ignored (forward compatible); an unknown top-level envelope field is rejected by the dispatcher with a plain-text400(see Errors below).GETcarries 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
alertIdunder the aliasid, both as a query parameter and as a payload key:?id=<uuid>and{ "payload": { "id": "<uuid>" } }work exactly likealertId. Precedence is queryalertId→ queryid→ payloadalertId→ payloadid; 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:
{ "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 bycreateAlert, 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:
{ "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:
PUTis routed but implements nothing, so everyPUT /action/<name>is501— including for actions that exist under another verb.- A known action sent with the wrong verb is also
501, not405.405is reserved for a verb outsideGET/POST/PUT/DELETE. GETnever decodes a body, so it can only produce the501row.- Only
POST,PUTandDELETEcan produce the plain-text400.
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
{ "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:
{
"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:
{
"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
{ "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).
{ "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)
{ "payload": { "alertId": "…", "subscriptionId": "…" } }
The admin may remove any subscription of the alert; a subscriber may remove its
own. Response 200:
{ "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
{ "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:
{
"eventId": "…",
"action": "trigger",
"serverTime": "2026-10-18T17:05:00.123Z",
"autoStopSeconds": 300,
"delivered": 2
}
deliveredis 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 thealarmStatusfallback instead.- Re-triggering an already-triggered alert is allowed and always produces a
new
eventId, restarting the auto-stop window. Clients must treat eacheventIdas 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).
{ "payload": { "alertId": "…", "adminToken": "…", "message": "All clear" } }
Response 200 — same shape as triggerAlert with "action": "disarm":
{
"eventId": "…",
"action": "disarm",
"serverTime": "2026-10-18T17:09:00.456Z",
"autoStopSeconds": 300,
"delivered": 2
}
autoStopSeconds echoes the alert's configured value (e.g. 300), not
0: disarming does not rewrite the stored column, and the same value rides the
broadcast disarm frame. Treat it as informational — "that is how long the
trigger would have rung" — never as a stop signal. Cancel the ring on
action: "disarm" (or on active: false from alarmStatus). A client that
waits for autoStopSeconds === 0 before stopping will keep ringing.
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": "…" } }
{
"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)
{ "payload": { "alertId": "…", "adminToken": "…" } }
Deletes the alert, its subscriptions and its event history (cascade). Response
200:
{ "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
{ "payload": { "alertId": "…", "adminToken": "…" } }
Response 200:
{ "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, or503 ERR_UNAVAILABLEduring shutdown). A non-GETrequest gets405 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:
{
"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:
{ "type": "ack", "eventId": "…" }
Client → server frames
ack — "I have this event and I am ringing":
{ "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.
{ "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
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
{
"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
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.