diff --git a/API.md b/API.md index e2013d2..1442ea0 100644 --- a/API.md +++ b/API.md @@ -15,6 +15,47 @@ 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)://`, 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 | @@ -67,8 +108,9 @@ Two further credentials exist, both **in the payload** (never in a header): - `adminToken` — the alert's admin secret. Minted once by `createAlert`, stored only as a SHA-256 hash server-side, and required by the destructive actions. -- `subscribeToken` — the shareable secret rendered as the QR code. It is - *rotatable* and is the only thing a joining client needs. +- `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. @@ -117,6 +159,7 @@ Consequences worth internalising: | `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 @@ -124,6 +167,14 @@ 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 @@ -145,10 +196,10 @@ GET-only** (`POST /action/me` is a `501`). `DELETE` accepts the two removals. | `removeSubscription` | DELETE, POST | — | admin (any row) or the subscriber (own row) | | `triggerAlert` | POST | required | admin | | `disarmAlert` | POST | required | admin | -| `alarmStatus` | GET, POST | — | admin **or** subscriber | +| `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 | -| `rotateSubscribeToken` | POST | required | admin | ### Shared-auth actions (`/auth/*` proxies) @@ -182,8 +233,7 @@ Creates an alert and makes the caller its admin. ```json { "payload": { "name": "Fire drill", - "message": "Leave the building now", - "autoStopSeconds": 300 + "message": "Leave the building now" } } ``` @@ -191,7 +241,6 @@ Creates an alert and makes the caller its admin. | --- | --- | --- | | `name` | string | required, 1–255 characters after trimming | | `message` | string | optional, ≤ 2000 bytes, the default text pushed with a trigger | -| `autoStopSeconds` | int | optional, `0`–`86400`; `0` (default) means "ring until disarmed" | Response `200`: @@ -205,8 +254,6 @@ Response `200`: "adminToken": "…", // shown ONCE, store it "subscribeToken": "…", "qrPayload": "https://alarm.dcntrlized.org/a/0f8f…e#…", - "autoStopSeconds": 300, - "tokenVersion": 1, "created": "2026-10-18T17:00:00Z" } ``` @@ -214,6 +261,19 @@ Response `200`: `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` @@ -231,8 +291,6 @@ Response `200`: "message": "Leave the building now", "status": "triggered", // "armed" | "triggered" | "disarmed" "active": true, // derived, see alarmStatus - "autoStopSeconds": 300, - "tokenVersion": 1, "created": "2026-10-18T17:00:00Z", "modified": "2026-10-18T17:05:00Z", "triggeredAt": "2026-10-18T17:05:00Z", @@ -284,11 +342,17 @@ Subscribes the caller's device. This is what the QR scan calls. `deviceId` is part of the subscription's identity, so it is **not optional**: missing or empty is `400 ERR_VALIDATION` (`deviceId is required`). Generate it -once per install (a random UUID persisted in app storage is enough) and never -rotate it — a new value makes the same user a separate subscription, which is +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 @@ -310,7 +374,7 @@ is *not* required for this read). ```json { "items": [ - { "id": "…", "userId": "…", "deviceId": "android-8f2c…", + { "id": "…", "userId": "…", "deviceLabel": "Pixel 7", "platform": "android", "enabled": true, "lastSeenAt": "2026-10-18T17:04:00Z", "created": "2026-10-18T16:00:00Z" } @@ -322,6 +386,19 @@ 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`. @@ -367,7 +444,6 @@ Response `200`: "eventId": "…", "action": "trigger", "serverTime": "2026-10-18T17:05:00.123Z", - "autoStopSeconds": 300, "delivered": 2 } ``` @@ -377,8 +453,45 @@ Response `200`: not connected (or are on another instance) are reached by the reconnect catch-up and by the `alarmStatus` fallback instead. - Re-triggering an already-triggered alert is allowed and always produces a - **new `eventId`**, restarting the auto-stop window. Clients must treat each - `eventId` as a new alarm. + **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. @@ -401,29 +514,33 @@ Response `200` — same shape as `triggerAlert` with `"action": "disarm"`: "eventId": "…", "action": "disarm", "serverTime": "2026-10-18T17:09:00.456Z", - "autoStopSeconds": 300, "delivered": 2 } ``` -`autoStopSeconds` echoes the alert's **configured** value (e.g. `300`), not -`0`: disarming does not rewrite the stored column, and the same value rides the -broadcast `disarm` frame. Treat it as informational — "that is how long the -trigger would have rung" — never as a stop signal. **Cancel the ring on -`action: "disarm"`** (or on `active: false` from `alarmStatus`). A client that -waits for `autoStopSeconds === 0` before stopping will keep ringing. +Disarm **ends the alert**, whatever its state: -Disarming an alert that is **not** currently active is `409 ERR_ALERT_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. -Errors: as `triggerAlert`, plus `ERR_ALERT_STATE`. +**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=` · -**POST** `{ "payload": { "alertId": "…" } }` +**GET** `/action/alarmStatus?alertId=[&deviceId=]` · +**POST** `{ "payload": { "alertId": "…" } }` (a `deviceId` in the payload is not +read; send it in the query if you want to pin the subscription) ```json { @@ -431,21 +548,107 @@ start when it may have been killed mid-alarm. "eventId": "…", "action": "trigger", "message": "Leave the building now", - "serverTime": "2026-10-18T17:05:00.123Z", - "autoStopSeconds": 300 + "serverTime": "2026-10-18T17:05:00.123Z" } ``` -`active` is **derived**, never a timer: it is true only when the newest event is -a `trigger`, the stored status is `triggered`, and (when `autoStopSeconds > 0`) -`now < triggeredAt + autoStopSeconds`. Nothing sweeps the database, so the -answer stays correct across restarts and across instances. When there is no -event yet the fields are empty and `active` is `false`. +`active` is **derived**, never a ringing timer: it is true only when the newest +event is a `trigger` and the stored status is `triggered`. The answer stays +correct across restarts and instances, and it stays `true` until the owner +disarms or the alert expires. (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" } } +``` + +Response `200`: + +```json +{ "alertId": "…", "eventId": "…", "acked": 1, "destroyed": false } +``` + +- 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. +- 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`. @@ -457,7 +660,8 @@ Errors: `ERR_VALIDATION`, `ERR_ALERT_NOT_FOUND`, `ERR_ALERT_NOT_SUBSCRIBED`, { "payload": { "alertId": "…", "adminToken": "…" } } ``` -Deletes the alert, its subscriptions and its event history (cascade). Response +Deletes the alert, its subscriptions, its event history and its delivery-ledger +rows (all `ON DELETE CASCADE` in the schema). Response `200`: ```json @@ -496,30 +700,6 @@ Keep the quiet zone at ≥ 4 modules; scanners reject codes without it. Errors: `ERR_VALIDATION`, `ERR_ALERT_NOT_FOUND`, `ERR_ALERT_NOT_SUBSCRIBED`, `ERR_UNAUTHORIZED`, `ERR_INTERNAL` (encoder failure — never expected). -## `rotateSubscribeToken` - -Invalidates the current QR and mints a new subscribe token. Existing -subscriptions keep working; only *joining* with the old token stops working. -Use it when the QR leaked. - -**POST** `/action/rotateSubscribeToken` - -```json -{ "payload": { "alertId": "…", "adminToken": "…" } } -``` - -Response `200`: - -```json -{ "alertId": "…", "subscribeToken": "…", - "qrPayload": "https://alarm.dcntrlized.org/a/…#…", - "tokenVersion": 2 } -``` - -Errors: as `triggerAlert`. - ---- - ## QR payload format The QR encodes exactly the `qrPayload` field: @@ -543,10 +723,18 @@ to a server by a browser — the token stays client-side until the app posts it ## Live protocol — `GET /ws/alarm` +The service path is: + ``` -ws://:8088/ws/alarm?alertId=&token=&accessToken=[&deviceId=] +/ws/alarm?alertId=&token=&accessToken=[&deviceId=] ``` +Through an instance (the supported path) that is +`wss:///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**: the access token identifies the user and the second token proves the relationship to the alert. @@ -586,16 +774,13 @@ from one fan-out: "eventId": "…", "action": "trigger", // "trigger" | "disarm" "message": "Leave the building now", - "serverTime": "2026-10-18T17:05:00.123Z", - "autoStopSeconds": 300 + "serverTime": "2026-10-18T17:05:00.123Z" } ``` -`ack` — the server's acknowledgement of a client ack: - -```json -{ "type": "ack", "eventId": "…" } -``` +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 @@ -633,11 +818,43 @@ candidate; the same applies to sending non-JSON frames. ### Reconnect catch-up -The service keeps the **last event per alert** in memory. On connect, if that -event is still active (a `trigger` whose auto-stop window has not expired), the -server sends it immediately — so a client that reconnects mid-alarm fires -without polling, and a client that reconnects *after* the alarm is over is never -re-alarmed. +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//…`) 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 @@ -651,15 +868,16 @@ subscribers of instance B as well. Duplicate deliveries are suppressed by ### Minimal client ```js +// Through an instance: wss:///api/alarm/ws/alarm?… const ws = new WebSocket( - `ws://alarm:8088/ws/alarm?alertId=${alertId}&token=${subscribeToken}` + - `&accessToken=${accessToken}&deviceId=${deviceId}`); + `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") { - // autoStopSeconds: 0 = ring until a disarm arrives; >0 = self-stop after N - ring(f.message, f.eventId, f.autoStopSeconds); // max volume, wake screen + // 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); @@ -675,6 +893,19 @@ 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` @@ -699,6 +930,8 @@ mount the shared-auth proxy. ## Worked example ```bash +# Through an instance (the supported path) the base is https:///api/alarm. +# On the dev host you can talk to the service directly for debugging: BASE=http://localhost:8088 # 1. access token (shared auth) @@ -709,7 +942,7 @@ TOKEN=$(curl -s -X POST "$BASE/auth/login" \ # 2. create the alert (keep adminToken!) curl -s -X POST "$BASE/action/createAlert" -H "X-Access-Token: $TOKEN" \ -H 'Content-Type: application/json' \ - -d '{"payload":{"name":"Fire drill","message":"Leave the building","autoStopSeconds":300}}' | jq + -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" \ @@ -728,6 +961,12 @@ curl -s -X POST "$BASE/action/triggerAlert" -H "X-Access-Token: $TOKEN" \ 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 @@ -736,9 +975,75 @@ All state lives in the shared PostgreSQL database, schema `alarm`: | Table | Contents | | --- | --- | -| `alarm.alerts` | one row per alert: name, message, admin user id, admin-token hash, subscribe token + version, status, timestamps, `auto_stop_seconds` | -| `alarm.subscriptions` | `(alert, user, device)` unique, label/platform, `enabled`, `last_seen_at` | +| `alarm.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:` 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.