Files
Admin 9f1ac9c25b Separate delivery receipts from dismissals
The client acks a frame as soon as it arrives — that is how an online device
stops being re-told — and the service recorded that ack in acked_at, the
column the drain-to-empty rule counts. Measured on an Android 15 emulator:
the owner's trigger reached the subscriber at .562 and the alert was
destroyed 27 ms later, mid-siren, so the owner's disarm answered
ERR_ALERT_NOT_FOUND.

alarm_deliveries now carries two timestamps. delivered_at is the receipt
(the WS ack frame and the offline check-in): it retires re-delivery and
nothing else. acked_at is the human dismissal (ackAlarm): only that ends the
alert, once every subscription's row is dismissed. Migration 011 adds the
column and replaces the pending index with an undelivered one.

ackAlarm also runs the drain check on every explicit dismissal, not only when
this call cleared a row: a receipted row still has to be dismissed.

Also fixed, found by the same probe run: db.Close left the closed pool as the
process singleton (a second probe inherited it), and the lifecycle probe's
three subscriptions shared one device id, which migration 010 made illegal.

Verified: both probes pass against the live dev database, including the new
receipt-vs-dismissal case; migration 011 applied to the dev DB.
2026-10-19 20:40:23 +01:00

50 KiB
Raw Permalink Blame History

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:

{ "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:

{ "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:

{ "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

{ "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:

{
  "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 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:

{
  "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.

{ "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).

{ "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)

{ "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",
  "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).

{ "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",
  "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)

{
  "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
{
  "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 delivered (delivered_at) by the act of returning this response, so the same uninterrupted alarm cannot produce a second 409. That is a receipt, not a dismissal: it does not end the alert.
  • On the live path the WebSocket ack frame records the same receipt, 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 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:

{ "payload": { "alertId": "…", "eventId": "…", "deviceId": "pixel-7" } }
{ "payload": { "alertId": "…", "eventId": "…" } }        // device token in X-Device-Token
{ "payload": { "alertId": "…" } }                        // dismiss everything owed

Response 200:

{ "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.
  • Receipt and dismissal are different facts. A client acks a frame as soon as it receives it (that is how an online device stops being told about the same alarm on every poll). That writes delivered_at, which retires re-delivery but is not a dismissal. Only ackAlarm writes acked_at, and only acked_at counts towards the drain-to-empty rule — so the alert survives while phones are still ringing even when everyone happened to be online.
  • Idempotent: a repeat ack (or an ack for an event the device never owed) returns acked: 0, never an error. The WebSocket ack frame does NOT dismiss an alarm — it only records the receipt.
  • acked counts the ledger rows dismissed for this device. When the last un-dismissed row of the alert is dismissed, the alert drains and is destroyed (reason delivered) — destroyed: true reports that intended outcome. A receipt (the WS ack frame) never counts here: it only sets delivered_at. The drain check runs on every explicit dismissal, even when this call cleared no row, because a receipted row is still undismissed. 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)

{ "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:

{ "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:

{
  "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":

{ "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

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 acks (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

// 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

{
  "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

# 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.