Four defects, three of them the same missing piece of the delivery ledger.
- listSubscriptions scanned nine destinations for ten columns and read a NULL
subscriber_user_id into a uuid. Every alert with an anonymous subscriber
answered 500 ("number of field descriptions must equal number of
destinations, got 10 and 9"), and the client renders a failed read as an
empty list — so 'Subscribed devices' said 0 while the row existed. The
listing now uses the same scan as every other subscription read.
- alarmStatus reported the alert's commanded state, not the caller's own. An
alert stays 'triggered' until the owner disarms or every subscriber acks, so
a device that had already dismissed the alarm was told active:true on every
poll and re-raised its takeover. active is now true only while the caller
still owes the newest trigger; the owner keeps seeing their own alert.
- ackAlarm required an eventId, which the native takeover launch does not
have. A dismissal that cannot name its event now clears the subscription's
whole outstanding set (AckAllDeliveries / AckAllForSubscriber), so the debt
cannot survive because the client could not echo a uuid.
- The device ledger probe (gated on DATABASE_URL, like the lifecycle probe)
pins the per-subscriber debt, the ack-all semantics and the cascade that
takes a device token with the destroyed alert.
Verified against the dev database and through Maestro: 2 subscriptions listed,
device A acked -> its status turns active:false while device B still gets the
409 check-in, ack-all without eventId clears the debt and drains the alert.
1108 lines
49 KiB
Markdown
1108 lines
49 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` | `{ "email": "…", "password": "…" }` (the hub identifies accounts by email) |
|
||
| `register` | POST | `POST /auth/register` | `{ "username": "…", "password": "…", "email": "…" }` |
|
||
| `refresh` | POST | `POST /auth/refresh` | `{ "refreshToken": "…" }` |
|
||
| `delete` | POST | `POST /auth/delete` | `{ "password": "…" }` |
|
||
|
||
Direct `/auth/*` routes exist for the same calls; the RN clients call those for
|
||
login/register (raw body, no envelope). These `/action/` variants are the
|
||
envelope-wrapped form of the same operations.
|
||
|
||
### Anonymous subscribers (rev 10)
|
||
|
||
Receiving alarms needs no account. There is **no subscriber account, synthetic
|
||
or otherwise**: the subscription row is the identity, and the service mints a
|
||
device token for it at join time.
|
||
|
||
1. `joinAlert` is called with **no `X-Access-Token`** — the shared subscribe
|
||
token from the QR is the only proof required. The response carries
|
||
`deviceToken`, returned **once**, on the join that created or rotated it.
|
||
2. Every later subscriber call sends that token as `X-Device-Token`
|
||
(`?deviceToken=` for the WebSocket upgrade, which cannot set headers):
|
||
`getAlert`, `alarmStatus`, `ackAlarm`, `removeSubscription`, `qrImage` and
|
||
`GET /ws/alarm`.
|
||
3. The token is scoped to exactly one alert and one subscription. It can read
|
||
that alert, ring/dismiss its own alarm and drop its own row — nothing else.
|
||
`createAlert`, `triggerAlert`, `disarmAlert`, `deleteAlert` and
|
||
`listSubscriptions` require an access token (`401 ERR_UNAUTHORIZED`);
|
||
owning an alert always needs an account.
|
||
4. Re-joining from the same device **rotates** the token: the old one stops
|
||
working immediately, which is how a lost token is recovered with the
|
||
subscribe token alone. A device is one subscription per alert.
|
||
5. `ALARM_ANONYMOUS=false` (env, default on) turns the unauthenticated path off:
|
||
`joinAlert` then requires an access token and stores the subscription against
|
||
that user, exactly like rev 9.
|
||
|
||
The token is 32 CSPRNG bytes (base64url), stored as plain SHA-256 (there is
|
||
nothing to stretch); the database never holds the token itself. No `auth.users`
|
||
row is created, so nothing outside `alarm.subscriptions` changes when a device
|
||
subscribes.
|
||
|
||
---
|
||
|
||
## `createAlert`
|
||
|
||
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` — **no access token required** (rev 10). The shared
|
||
subscribe token is the authorisation; the response includes a `deviceToken`
|
||
(returned once) that authenticates this device on its later subscriber calls.
|
||
With `ALARM_ANONYMOUS=false` the call requires `X-Access-Token` and no device
|
||
token is issued.
|
||
|
||
```json
|
||
{ "payload": {
|
||
"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`, the stored status is `triggered`, **and this caller still
|
||
owes the alarm** (an unacknowledged delivery row for its subscription). The
|
||
answer stays correct across restarts and instances, and it stays `true` until
|
||
the owner disarms, the alert expires, or this caller acknowledges.
|
||
|
||
The caller-scoped `active` is what makes dismissal final: an alert stays
|
||
commanded `triggered` until the owner disarms it or *every* subscriber ack, so a
|
||
device that already dismissed the alarm would otherwise be told `active: true`
|
||
on every poll and re-raise its takeover. A client must treat `active: false`
|
||
with a stale `eventId` as "nothing owed by this device", not as a new trigger.
|
||
The alert's owner (who has no delivery row) keeps seeing their own alert's
|
||
commanded state. (There *is* a sweeper, but it destroys expired
|
||
alerts — it never changes an event's meaning.) An expired or destroyed alert is
|
||
`ERR_ALERT_NOT_FOUND` here like everywhere else. When there is no event yet the
|
||
fields are empty and `active` is `false`.
|
||
|
||
`serverTime` is the event's timestamp (or the server's clock when there is no
|
||
event) — use it to correct client clock skew rather than comparing with the
|
||
device clock.
|
||
|
||
### Offline check-in — `409 ERR_ALARM_TRIGGERED_OFFLINE`
|
||
|
||
The same call is the offline check-in. A trigger writes a **durable delivery
|
||
ledger row** for every enabled subscription
|
||
(`alarm.alarm_deliveries`), so a device that was offline at trigger time has a
|
||
record owing it an alarm even though `pg_notify` and the in-memory catch-up
|
||
never reached it. When that device calls `alarmStatus`, it gets:
|
||
|
||
```
|
||
HTTP/1.1 409 Conflict
|
||
```
|
||
|
||
```json
|
||
{
|
||
"error": { "code": "ERR_ALARM_TRIGGERED_OFFLINE", "message": "…" },
|
||
"alarm": {
|
||
"type": "alarm",
|
||
"alertId": "0f8fad5b-…",
|
||
"eventId": "…",
|
||
"action": "trigger",
|
||
"message": "Leave the building now",
|
||
"serverTime": "2026-10-18T17:05:00.123Z"
|
||
}
|
||
}
|
||
```
|
||
|
||
**This is a success-shaped alarm signal — the HTTP status is `409` but the
|
||
response means "ring", never "the request failed".** `error` is the platform
|
||
envelope verbatim (do not expect frame fields inside it, and there is no third
|
||
top-level key): the frame lives in the sibling `alarm` object, shaped exactly
|
||
like the WebSocket frame, so one parser serves both paths. A client that renders
|
||
this as an error has failed to deliver the alarm. Handle `alarm` exactly like a
|
||
live `trigger` frame: max volume, wake the screen, `ack`, and stop on a disarm.
|
||
|
||
The socket is the primary path now: the same ledger is replayed on WebSocket
|
||
connect (see "Reconnect catch-up"), which reaches the native service even when
|
||
the JS layer is not running. The 409 is the secondary path for a client that is
|
||
running and polling but has no socket — both settle the same rows.
|
||
|
||
- The rows are marked 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" } }
|
||
{ "payload": { "alertId": "…", "eventId": "…" } } // device token in X-Device-Token
|
||
{ "payload": { "alertId": "…" } } // dismiss everything owed
|
||
```
|
||
|
||
Response `200`:
|
||
|
||
```json
|
||
{ "alertId": "…", "eventId": "…", "acked": 1, "destroyed": false }
|
||
// with no eventId: "eventId": "" and acked counts every row that was cleared
|
||
```
|
||
|
||
- Clearing is **ack-driven**, never response-driven: `alarmStatus` only *reports*
|
||
debt (409 + frame) and clears nothing, so a 409 lost in transit does not
|
||
forfeit the alarm — the next check-in finds the same row.
|
||
- 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. Note that a
|
||
destroyed alert takes its subscriptions with it, so a device token for it stops
|
||
resolving (the next call is `401 ERR_UNAUTHORIZED`, not `404`); clients must
|
||
treat that as "the subscription is over", not as a retryable failure.
|
||
- **`eventId` is optional.** Without it the dismissal retires the caller's whole
|
||
outstanding set on the alert. That exists because the native takeover launch
|
||
carries only the alert: a client that dismissed an alarm without being able to
|
||
name its event must still be able to clear the debt, or the replay loop it
|
||
could not describe keeps ringing forever.
|
||
- The alert owner has no delivery row, so an owner ack is a well-formed no-op.
|
||
- Subscribers must pass `deviceId` (raw; the server hashes it). A device can only
|
||
clear its own row.
|
||
|
||
Errors: `ERR_VALIDATION`, `ERR_ALERT_NOT_FOUND`, `ERR_ALERT_NOT_SUBSCRIBED`,
|
||
`ERR_UNAUTHORIZED`.
|
||
|
||
## `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>]
|
||
/ws/alarm?alertId=<uuid>&token=<subscribeToken>&deviceToken=<deviceToken>
|
||
```
|
||
|
||
Through an instance (the supported path) that is
|
||
`wss://<instance>/api/alarm/ws/alarm?…`; on the dev host the raw service port is
|
||
`ws://localhost:8088/ws/alarm?…`. The data plane relays control frames end to
|
||
end, so the 20s ping / 60s pong accounting below is measured between client and
|
||
service, not against the proxy.
|
||
|
||
The socket is **authenticated, not anonymous**: one of two credentials
|
||
identifies the subscriber — an access token (an account) or a device token
|
||
(rev 10, a device that joined without an account; sent as `?deviceToken=` because
|
||
the upgrade cannot set headers) — and `token` proves the relationship to the
|
||
alert in both cases.
|
||
|
||
### Authentication
|
||
|
||
| 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.
|