This commit is contained in:
2026-10-18 22:31:16 +01:00
commit ec38e680d6
64 changed files with 7222 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
# Keep the build context small and deterministic. Never ship host artifacts.
.git
.gitignore
.vscode
src/tmp
src/logs
+75
View File
@@ -0,0 +1,75 @@
# Template action to generate OCI-compliant containers
# Don't forget to change the template variables if any were missed
name: Build and Publish Container
on:
push:
tags:
- 'v*'
jobs:
build-and-push:
runs-on: alpine-latest
steps:
- name: Install Dependencies
shell: sh
run: apk add --no-cache bash docker-cli docker-cli-buildx git
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0 # Get full git history
- name: Log in to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: ${{ secrets.REGISTRY }}
username: ${{ secrets.USERNAME }}
password: ${{ secrets.TOKEN }}
- name: Normalize release version
id: version
shell: sh
run: |
ORG_REPO="$(printf '%s' "${{ gitea.repository }}" | tr '[:upper:]' '[:lower:]')"
echo "image_base=${{ secrets.REGISTRY }}/${ORG_REPO}" >> "${GITHUB_OUTPUT}"
TAG="${GITHUB_REF_NAME#v}"
PRERELEASE=""
case "$TAG" in
*-*) PRERELEASE="-${TAG#*-}"; TAG="${TAG%%-*}";;
esac
IFS='.' read -r MAJOR MINOR PATCH <<EOF
$TAG
EOF
MAJOR="${MAJOR:-0}"
MINOR="${MINOR:-0}"
PATCH="${PATCH:-0}"
VERSION="${MAJOR}.${MINOR}.${PATCH}${PRERELEASE}"
echo "version=${VERSION}" >> "${GITHUB_OUTPUT}"
- name: Extract metadata (labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ steps.version.outputs.image_base }}
labels: |
org.opencontainers.image.title=Test Service
org.opencontainers.image.vendor=Dcntrlized
org.opencontainers.image.description=A test service used to test service creation and deployment
org.opencontainers.image.authors=Admin <[email protected]>
org.opencontainers.image.version=${{ steps.version.outputs.version }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
push: true
tags: |
${{ steps.version.outputs.image_base }}:${{ steps.version.outputs.version }}
${{ steps.version.outputs.image_base }}:latest
labels: ${{ steps.meta.outputs.labels }}
build-args: |
IMAGE_VERSION=${{ steps.version.outputs.version }}
IMAGE_REVISION=${{ gitea.sha }}
+11
View File
@@ -0,0 +1,11 @@
# Go build artifacts (Air runs inside the dev container as root)
src/tmp/
src/logs/
# Logger writes its logs/ dir relative to the working directory, so package
# tests (which run with the package dir as cwd) can drop logs under src/*/.
src/handlers/config/logs/
src/handlers/manifest/logs/
src/actions/logs/
src/realtime/logs/
src/db/logs/
+14
View File
@@ -0,0 +1,14 @@
Copyright (C) {{.Year}} {{.Holder}}
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
+30
View File
@@ -0,0 +1,30 @@
# addlicense ignore patterns (doublestar globs, relative to the repo root).
# Blank lines and lines starting with '#' are skipped. addlicense validates every
# pattern and skips file types it does not understand on its own, so this file is
# only the escape hatch for files that must not carry an AGPL header.
# generated / vendored / build output
**/node_modules/**
**/vendor/**
**/tmp/**
**/dist/**
**/build/**
# non-code and config files
**/*.md
**/*.json
**/*.yaml
**/*.yml
**/*.toml
**/*.lock
**/*.sum
**/*.mod
**/*.log
**/*.txt
**/*.debug
# repo plumbing
.licenseignore
.gitignore
.gitmodules
.vscode/**
+50
View File
@@ -0,0 +1,50 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Debug Backend",
"type": "go",
"request": "attach",
"mode": "remote",
"port": 2349,
"host": "127.0.0.1",
"debugAdapter": "legacy",
"substitutePath": [
{
"from": "${workspaceFolder}/src",
"to": "/app"
}
],
"cwd": "${workspaceFolder}/src",
"showLog": true,
"preLaunchTask": "Launch Backend",
"presentation": {
"hidden": false,
"group": "Alarm",
"order": 0
}
},
{
"name": "Attach to Alarm (Air)",
"type": "go",
"request": "attach",
"mode": "remote",
"port": 2349,
"host": "127.0.0.1",
"debugAdapter": "legacy",
"substitutePath": [
{
"from": "${workspaceFolder}/src",
"to": "/app"
}
],
"cwd": "${workspaceFolder}/src",
"showLog": true,
"presentation": {
"hidden": false,
"group": "Alarm",
"order": 1
}
}
]
}
+54
View File
@@ -0,0 +1,54 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Launch Backend",
"type": "shell",
"command": "docker compose -f docker-compose.debug.yaml up -d && until nc -z 127.0.0.1 2349 >/dev/null 2>&1; do sleep 1; done && echo DAE_BACKEND_READY",
"isBackground": true,
"problemMatcher": [
{
"pattern": {
"regexp": "^.*$"
},
"background": {
"activeOnStart": true,
"beginsPattern": ".*",
"endsPattern": "DAE_BACKEND_READY"
}
}
],
"presentation": {
"reveal": "always",
"panel": "shared"
}
},
{
"label": "Follow Backend Logs",
"type": "shell",
"command": "docker compose -f docker-compose.debug.yaml logs -f",
"presentation": {
"reveal": "always",
"panel": "shared"
}
},
{
"label": "Stop Backend",
"type": "shell",
"command": "docker compose -f docker-compose.debug.yaml down",
"presentation": {
"reveal": "always",
"panel": "shared"
}
},
{
"label": "Rebuild Backend Image",
"type": "shell",
"command": "docker compose -f docker-compose.debug.yaml up -d --build",
"presentation": {
"reveal": "always",
"panel": "shared"
}
}
]
}
+744
View File
@@ -0,0 +1,744 @@
# Alarm — service API (v1, frozen)
The Alarm service is the server half of the Dcntrlized alert feature: one user
(the **admin**) creates an alert and holds an admin token; everybody else joins
by scanning the alert's **subscribe token** as a QR code; the admin can trigger
and disarm the alert at will, and a trigger must fire **live** on every
subscribed client.
- Container / DNS name: `alarm`
- One plaintext listener inside the container: `:8080` (dev host port `8088`)
- Module: `git.dcntrlized.org/Dcntrlized/Alarm`
- Image: `192.168.122.183:3000/dcntrlized/alarm:dev`
Everything — the API, the auth proxy and the WebSocket — rides that **single
port**. There is no second listener and no TLS on the service itself (an edge
proxy terminates TLS).
## Routes
| Route | Purpose |
| --- | --- |
| `GET /_manifest` | service manifest consumed by Maestro |
| `/auth/*` | shared-auth proxy (`dcntrlized-auth`), unchanged from every other service |
| `/action/{actionName}` | the action API documented below |
| `GET /ws/alarm` | the live alarm socket |
`/ws/alarm` is deliberately registered on the **outer** mux, outside the
logging/CORS chain: the connection is hijacked and the subscribe token travels
in the query string, so it must never reach a request logger.
## Envelope and authentication
`/action/*` uses the platform envelope:
```json
{ "payload": { "...": "action specific" } }
```
- `POST`, `PUT` and `DELETE` read the envelope from the body. Unknown fields
**inside `payload` are ignored** (forward compatible); an unknown top-level
envelope field is rejected by the dispatcher with a plain-text `400` (see
*Errors* below).
- `GET` carries no body: the same actions take their arguments from the query
string, e.g. `GET /action/getAlert?alertId=…`.
- Every action that addresses an alert accepts **`alertId` under the alias
`id`**, both as a query parameter and as a payload key:
`?id=<uuid>` and `{ "payload": { "id": "<uuid>" } }` work exactly like
`alertId`. Precedence is query `alertId` → query `id` → payload `alertId` →
payload `id`; the first non-empty one wins.
**Every alert action requires a verified access token** in the `X-Access-Token`
header (minted by `dcntrlized-auth`). A missing, malformed or expired token is
always:
```json
{ "error": { "code": "ERR_UNAUTHORIZED", "message": "…" } }
```
with HTTP `401`. The service never trusts a user id, name or email from the
payload — identity comes from the token only. The shared-auth proxies
(`login`, `register`, `refresh`, `delete`, `logout`, `me`) are the exception:
`login`/`register` mint the token, `me`/`logout`/`refresh`/`delete` forward
whatever header the caller sent and let `dcntrlized-auth` decide.
Two further credentials exist, both **in the payload** (never in a header):
- `adminToken` — the alert's admin secret. Minted once by `createAlert`,
stored only as a SHA-256 hash server-side, and required by the destructive
actions.
- `subscribeToken` — the shareable secret rendered as the QR code. It is
*rotatable* and is the only thing a joining client needs.
Errors are **not uniform across the request pipeline**. Clients must know
where the boundary is.
**Action-level errors** — everything the action handlers themselves return —
use the platform envelope:
```json
{ "error": { "code": "ERR_…", "message": "human readable" } }
```
That covers every code in the table below (401/403/404/409/500) as well as the
WebSocket rejections.
**Dispatch-level errors** are the exception. They are produced by the layer
that routes `/action/{actionName}` by HTTP method, *before* any handler or
error helper runs, and they are **plain text** (`text/plain; charset=utf-8`),
never JSON. A client that calls `JSON.parse` on every error body throws on
exactly these — check `Content-Type` first, and treat a non-JSON body as a
generic failure:
| Situation | HTTP | Body | Example |
| --- | --- | --- | --- |
| unknown action name for this verb | `501` | `Action not implemented` | `POST /action/getAlerts` (typo), `GET /action/triggerAlert`, **any** `PUT /action/<name>` |
| malformed request envelope | `400` | the JSON decoder's message | body is not valid JSON, has an unknown **top-level** field (`DisallowUnknownFields`), or cannot be decoded into the envelope — e.g. `json: unknown field "payloads"` |
| unsupported HTTP verb | `405` | `method not allowed` | `PATCH /action/createAlert` |
Consequences worth internalising:
- `PUT` is routed but implements nothing, so **every** `PUT /action/<name>` is
`501` — including for actions that exist under another verb.
- A known action sent with the *wrong* verb is also `501`, not `405`. `405` is
reserved for a verb outside `GET`/`POST`/`PUT`/`DELETE`.
- `GET` never decodes a body, so it can only produce the `501` row.
- Only `POST`, `PUT` and `DELETE` can produce the plain-text `400`.
## Error codes
| Code | HTTP | Meaning |
| --- | --- | --- |
| `ERR_VALIDATION` | 400 | malformed payload, bad uuid, field too long, bad query parameter |
| `ERR_UNAUTHORIZED` | 401 | missing/invalid access token |
| `ERR_ALERT_TOKEN_INVALID` | 403 | the `adminToken` or `subscribeToken` does not match this alert |
| `ERR_ALERT_NOT_ADMIN` | 403 | a valid access token, but not the alert's admin, for an admin-only action |
| `ERR_ALERT_NOT_FOUND` | 404 | no such alert (also returned instead of 403 where hiding existence matters) |
| `ERR_ALERT_NOT_SUBSCRIBED` | 404 | caller is neither the admin nor a subscriber of this alert |
| `ERR_ALERT_ALREADY_JOINED` | 409 | unique-constraint race on `(alertId, user, deviceId)`; the normal re-scan path is idempotent and returns `200` |
| `ERR_ALERT_STATE` | 409 | the action is illegal in the alert's current state (e.g. disarming a non-triggered alert, deleting a live one) |
| `ERR_INTERNAL` | 500 | unexpected server/database failure |
`ERR_METHOD_NOT_ALLOWED` (405) and `ERR_UNAVAILABLE` (503) also exist on the
WebSocket endpoint — see below. These codes only ever appear in a JSON
envelope; the dispatch-level `405` described above is plain text and carries no
code.
## Action summary
The dispatcher routes by HTTP method (`GET`, `POST`, `PUT`, `DELETE`); in every
case the action name is the path segment after `/action/`.
`POST` accepts every **alert** action plus the shared-auth proxies. `GET`
accepts the read-only alert subset plus `me` and `logout` — **`me` is
GET-only** (`POST /action/me` is a `501`). `DELETE` accepts the two removals.
`PUT` implements nothing at all.
### Alert actions
| Action | Verbs | Admin token | Access |
| --- | --- | --- | --- |
| `createAlert` | POST | — | any authenticated user becomes the admin |
| `getAlert` | GET, POST | — | admin **or** subscriber |
| `joinAlert` | POST | — | any authenticated user holding the subscribe token |
| `listSubscriptions` | GET, POST | — | alert admin only: the access-token user must be the alert's `adminUserId`; **no** admin token is used |
| `removeSubscription` | DELETE, POST | — | admin (any row) or the subscriber (own row) |
| `triggerAlert` | POST | required | admin |
| `disarmAlert` | POST | required | admin |
| `alarmStatus` | GET, POST | — | admin **or** subscriber |
| `deleteAlert` | DELETE, POST | required | admin |
| `qrImage` | GET, POST | — | admin **or** subscriber |
| `rotateSubscribeToken` | POST | required | admin |
### Shared-auth actions (`/auth/*` proxies)
Thin proxies to `dcntrlized-auth`, kept on `/action/` for the RN clients that
send everything through one path. They take the same `{payload}` envelope; the
payload is forwarded as the raw auth body and the auth response is relayed
as-is, unwrapped (so these return the auth service's own body shape, not the
alert shapes documented here).
| Action | Verbs | Proxies | Payload |
| --- | --- | --- | --- |
| `me` | GET | `GET /auth/me` | — (access token header only) |
| `logout` | GET, POST | `POST /auth/logout` | POST: `{ "refreshToken": "…" }`; GET: bodyless no-op |
| `login` | POST | `POST /auth/login` | `{ "username": "…", "password": "…" }` |
| `register` | POST | `POST /auth/register` | `{ "username": "…", "password": "…", "email": "…" }` |
| `refresh` | POST | `POST /auth/refresh` | `{ "refreshToken": "…" }` |
| `delete` | POST | `POST /auth/delete` | `{ "password": "…" }` |
Direct `/auth/*` routes exist for the same calls; the RN clients call those for
login/register (raw body, no envelope). These `/action/` variants are the
envelope-wrapped form of the same operations.
---
## `createAlert`
Creates an alert and makes the caller its admin.
**POST** `/action/createAlert`
```json
{ "payload": {
"name": "Fire drill",
"message": "Leave the building now",
"autoStopSeconds": 300
} }
```
| Field | Type | Rules |
| --- | --- | --- |
| `name` | string | required, 1–255 characters after trimming |
| `message` | string | optional, ≤ 2000 bytes, the default text pushed with a trigger |
| `autoStopSeconds` | int | optional, `0`–`86400`; `0` (default) means "ring until disarmed" |
Response `200`:
```json
{
"alertId": "0f8fad5b-d9cb-469f-a165-70867728950e",
"name": "Fire drill",
"message": "Leave the building now",
"status": "armed",
"active": false,
"adminToken": "…", // shown ONCE, store it
"subscribeToken": "…",
"qrPayload": "https://alarm.dcntrlized.org/a/0f8f…e#…",
"autoStopSeconds": 300,
"tokenVersion": 1,
"created": "2026-10-18T17:00:00Z"
}
```
`adminToken` is returned **only here** (and nowhere else, ever). The server
keeps a hash. Losing it means losing admin rights over that alert.
Errors: `ERR_VALIDATION`, `ERR_UNAUTHORIZED`.
## `getAlert`
Returns the alert, the caller's relationship to it, and the newest event.
**GET** `/action/getAlert?alertId=<uuid>` · **POST** `{ "payload": { "alertId": "…" } }`
Response `200`:
```json
{
"alertId": "…",
"name": "Fire drill",
"message": "Leave the building now",
"status": "triggered", // "armed" | "triggered" | "disarmed"
"active": true, // derived, see alarmStatus
"autoStopSeconds": 300,
"tokenVersion": 1,
"created": "2026-10-18T17:00:00Z",
"modified": "2026-10-18T17:05:00Z",
"triggeredAt": "2026-10-18T17:05:00Z",
"qrPayload": "https://alarm.dcntrlized.org/a/…#…",
"isAdmin": true,
"subscriptionCount": 2,
"lastEvent": {
"eventId": "…", "action": "trigger", "message": "Leave the building now",
"serverTime": "2026-10-18T17:05:00Z"
}
}
```
`qrPayload` is present for the admin and for subscribers (the subscriber already
holds that token). `isAdmin` tells the client which controls to render.
Optional fields are **omitted, never sent as `null`** (`omitempty` on the
wire). That covers `triggeredAt` (absent until the first trigger — and *kept*
after a disarm, so it always means "when it last rang"), `disarmedAt` (absent
until a disarm) and `lastEvent` (absent while the alert has no events). Test for
presence, not for `null`.
Errors: `ERR_VALIDATION`, `ERR_ALERT_NOT_FOUND`, `ERR_ALERT_NOT_SUBSCRIBED`,
`ERR_UNAUTHORIZED`.
## `joinAlert`
Subscribes the caller's device. This is what the QR scan calls.
**POST** `/action/joinAlert`
```json
{ "payload": {
"alertId": "0f8fad5b-…",
"subscribeToken": "…",
"deviceId": "android-8f2c…",
"deviceLabel": "Pixel 7",
"platform": "android"
} }
```
| Field | Type | Rules |
| --- | --- | --- |
| `alertId` | uuid | required (the scanner takes it from the QR payload) |
| `subscribeToken` | string | required, 16–64 url-safe characters, must match the alert |
| `deviceId` | string | **required**, 1–128 characters after trimming, no control characters — the client generates and persists a stable per-install id and sends it on every join |
| `deviceLabel` | string | optional, ≤ 128 chars |
| `platform` | string | optional, ≤ 32 chars (`android`, `ios`, …) |
`deviceId` is part of the subscription's identity, so it is **not optional**:
missing or empty is `400 ERR_VALIDATION` (`deviceId is required`). Generate it
once per install (a random UUID persisted in app storage is enough) and never
rotate it — a new value makes the same user a separate subscription, which is
exactly the "second device" behaviour. Reuse the same value as `?deviceId=` on
`/ws/alarm` so the right subscription's `last_seen_at` is refreshed.
Response `200` — the subscription view (see `listSubscriptions`).
**Idempotent per `(alertId, user, deviceId)`**: re-scanning the same QR on the
same device returns `200` with the same subscription id and creates no new row.
The same user scanning on a *second* device creates a second subscription.
`ERR_ALERT_ALREADY_JOINED` is only the 409 fallback for a concurrent duplicate
insert race.
Errors: `ERR_VALIDATION`, `ERR_ALERT_NOT_FOUND`, `ERR_ALERT_TOKEN_INVALID`,
`ERR_ALERT_ALREADY_JOINED`, `ERR_UNAUTHORIZED`.
## `listSubscriptions`
**GET** `/action/listSubscriptions?alertId=<uuid>` ·
**POST** `{ "payload": { "alertId": "…" } }`
Admin only (the access token must belong to the alert's admin — the admin token
is *not* required for this read).
```json
{ "items": [
{ "id": "…", "userId": "…", "deviceId": "android-8f2c…",
"deviceLabel": "Pixel 7", "platform": "android",
"enabled": true, "lastSeenAt": "2026-10-18T17:04:00Z",
"created": "2026-10-18T16:00:00Z" }
] }
```
`username` is intentionally **never** populated: it lives in the auth service's
schema and Alarm does not read another service's tables. The field is omitted
rather than sent empty. `lastSeenAt` is refreshed whenever that subscription's
socket connects or heartbeats.
Errors: `ERR_VALIDATION`, `ERR_ALERT_NOT_FOUND`, `ERR_ALERT_NOT_ADMIN`,
`ERR_UNAUTHORIZED`.
## `removeSubscription`
**DELETE** `/action/removeSubscription` · **POST** (same envelope)
```json
{ "payload": { "alertId": "…", "subscriptionId": "…" } }
```
The admin may remove any subscription of the alert; a subscriber may remove its
own. Response `200`:
```json
{ "alertId": "…", "subscriptionId": "…", "removed": true }
```
Errors: `ERR_VALIDATION`, `ERR_ALERT_NOT_FOUND`, `ERR_ALERT_NOT_SUBSCRIBED`,
`ERR_ALERT_NOT_ADMIN`, `ERR_UNAUTHORIZED`.
## `triggerAlert`
Fires the alarm **live** on every subscriber of every instance.
**POST** `/action/triggerAlert`
```json
{ "payload": {
"alertId": "0f8fad5b-…",
"adminToken": "…",
"message": "Optional per-trigger override"
} }
```
`message` is optional; when omitted the alert's stored message is used. The
override is **not** persisted — it applies to this event only.
Response `200`:
```json
{
"eventId": "…",
"action": "trigger",
"serverTime": "2026-10-18T17:05:00.123Z",
"autoStopSeconds": 300,
"delivered": 2
}
```
- `delivered` is the number of **live WebSocket clients on this instance** that
the frame was written to. It is not a delivery guarantee: clients that were
not connected (or are on another instance) are reached by the reconnect
catch-up and by the `alarmStatus` fallback instead.
- Re-triggering an already-triggered alert is allowed and always produces a
**new `eventId`**, restarting the auto-stop window. Clients must treat each
`eventId` as a new alarm.
- The alert state is stored **before** the fan-out, so a client that reconnects
immediately after the call still sees the live event.
Errors: `ERR_VALIDATION`, `ERR_ALERT_NOT_FOUND`, `ERR_ALERT_TOKEN_INVALID`,
`ERR_ALERT_NOT_ADMIN`, `ERR_UNAUTHORIZED`.
## `disarmAlert`
**POST** `/action/disarmAlert` — same payload as `triggerAlert` (the `message`
override applies to the disarm event the same way).
```json
{ "payload": { "alertId": "…", "adminToken": "…", "message": "All clear" } }
```
Response `200` — same shape as `triggerAlert` with `"action": "disarm"`:
```json
{
"eventId": "…",
"action": "disarm",
"serverTime": "2026-10-18T17:09:00.456Z",
"autoStopSeconds": 300,
"delivered": 2
}
```
`autoStopSeconds` echoes the alert's **configured** value (e.g. `300`), not
`0`: disarming does not rewrite the stored column, and the same value rides the
broadcast `disarm` frame. Treat it as informational — "that is how long the
trigger would have rung" — never as a stop signal. **Cancel the ring on
`action: "disarm"`** (or on `active: false` from `alarmStatus`). A client that
waits for `autoStopSeconds === 0` before stopping will keep ringing.
Disarming an alert that is **not** currently active is `409 ERR_ALERT_STATE`.
Errors: as `triggerAlert`, plus `ERR_ALERT_STATE`.
## `alarmStatus`
The cheap, indexed fallback a client polls when its socket is down, or on cold
start when it may have been killed mid-alarm.
**GET** `/action/alarmStatus?alertId=<uuid>` ·
**POST** `{ "payload": { "alertId": "…" } }`
```json
{
"active": true,
"eventId": "…",
"action": "trigger",
"message": "Leave the building now",
"serverTime": "2026-10-18T17:05:00.123Z",
"autoStopSeconds": 300
}
```
`active` is **derived**, never a timer: it is true only when the newest event is
a `trigger`, the stored status is `triggered`, and (when `autoStopSeconds > 0`)
`now < triggeredAt + autoStopSeconds`. Nothing sweeps the database, so the
answer stays correct across restarts and across instances. When there is no
event yet the fields are empty and `active` is `false`.
`serverTime` is the event's timestamp (or the server's clock when there is no
event) — use it to correct client clock skew rather than comparing with the
device clock.
Errors: `ERR_VALIDATION`, `ERR_ALERT_NOT_FOUND`, `ERR_ALERT_NOT_SUBSCRIBED`,
`ERR_UNAUTHORIZED`.
## `deleteAlert`
**DELETE** `/action/deleteAlert` · **POST** (same envelope)
```json
{ "payload": { "alertId": "…", "adminToken": "…" } }
```
Deletes the alert, its subscriptions and its event history (cascade). Response
`200`:
```json
{ "alertId": "…", "deleted": true }
```
Refused with `409 ERR_ALERT_STATE` while the alert is active — disarm first, so
a ringing alarm cannot be deleted out from under its subscribers.
Errors: `ERR_VALIDATION`, `ERR_ALERT_NOT_FOUND`, `ERR_ALERT_TOKEN_INVALID`,
`ERR_ALERT_NOT_ADMIN`, `ERR_ALERT_STATE`, `ERR_UNAUTHORIZED`.
## `qrImage`
Renders the alert's subscribe QR as a PNG.
**GET** `/action/qrImage?alertId=<uuid>&scale=8&quietZone=4` ·
**POST** `{ "payload": { "alertId": "…" } }`
| Query | Default | Range |
| --- | --- | --- |
| `scale` | `8` | 1–64 pixels per module |
| `quietZone` (alias `margin`) | `4` | 4–32 modules |
A quiet zone below **4 modules** is rejected with `400 ERR_VALIDATION`: the
ISO/IEC 18004 minimum is 4 and smaller margins produce symbols real scanners
refuse (measured: `zbarimg` returns "no symbol" at 0–3).
Response `200` is **binary**, not JSON: `Content-Type: image/png`,
`Cache-Control: no-store`, `X-Content-Type-Options: nosniff`. The image encodes
exactly `qrPayload` (below). The QR is byte-mode, EC level **M**, version
chosen automatically from the payload length.
Keep the quiet zone at ≥ 4 modules; scanners reject codes without it.
Errors: `ERR_VALIDATION`, `ERR_ALERT_NOT_FOUND`, `ERR_ALERT_NOT_SUBSCRIBED`,
`ERR_UNAUTHORIZED`, `ERR_INTERNAL` (encoder failure — never expected).
## `rotateSubscribeToken`
Invalidates the current QR and mints a new subscribe token. Existing
subscriptions keep working; only *joining* with the old token stops working.
Use it when the QR leaked.
**POST** `/action/rotateSubscribeToken`
```json
{ "payload": { "alertId": "…", "adminToken": "…" } }
```
Response `200`:
```json
{ "alertId": "…", "subscribeToken": "…",
"qrPayload": "https://alarm.dcntrlized.org/a/…#…",
"tokenVersion": 2 }
```
Errors: as `triggerAlert`.
---
## QR payload format
The QR encodes exactly the `qrPayload` field:
```
https://alarm.dcntrlized.org/a/<alertId>#<subscribeToken>
```
An app-scheme deep link carries the same pair for in-app scanners:
```
dcntrlized-alarm://join?alert=<alertId>&token=<subscribeToken>
```
`alertId` is a UUID and `subscribeToken` is 16–64 url-safe characters
(`A–Z a–z 0–9 - _`). The fragment is used because a URL fragment is never sent
to a server by a browser — the token stays client-side until the app posts it to
`joinAlert`.
---
## Live protocol — `GET /ws/alarm`
```
ws://<host>:8088/ws/alarm?alertId=<uuid>&token=<adminToken|subscribeToken>&accessToken=<token>[&deviceId=<id>]
```
The socket is **authenticated, not anonymous**: the access token identifies the
user and the second token proves the relationship to the alert.
### Authentication
| Credential | Where | Required |
| --- | --- | --- |
| access token (who you are) | `X-Access-Token` header **or** `?accessToken=` / `?access_token=` | yes |
| `alertId` | query | yes, valid uuid |
| `token` | query | yes — the admin token **or** the subscribe token |
| `deviceId` | query | optional; the `deviceId` used at `joinAlert` — selects which subscription's `last_seen_at` is refreshed (without it the oldest subscription is used) |
- admin token match **and** the caller is the alert's admin → accepted as the
admin connection (no subscription required);
- subscribe token match **and** the caller has a subscription on that alert →
accepted as a subscriber connection (`?deviceId=` selects the subscription,
otherwise the oldest one is used);
- anything else is rejected **before the upgrade** with the usual JSON error
(`401 ERR_UNAUTHORIZED`, `403 ERR_ALERT_TOKEN_INVALID`,
`403 ERR_ALERT_NOT_ADMIN`, `404 ERR_ALERT_NOT_SUBSCRIBED`,
`404 ERR_ALERT_NOT_FOUND`, or `503 ERR_UNAVAILABLE` during shutdown).
A non-`GET` request gets `405 ERR_METHOD_NOT_ALLOWED`.
A header is preferred for the access token (headers do not appear in access
logs); the query form exists because browsers cannot set headers on a WebSocket
handshake.
### Server → client frames
`alarm` — the alarm event. Every subscribed client receives the **same frame**
from one fan-out:
```json
{
"type": "alarm",
"alertId": "0f8fad5b-…",
"eventId": "…",
"action": "trigger", // "trigger" | "disarm"
"message": "Leave the building now",
"serverTime": "2026-10-18T17:05:00.123Z",
"autoStopSeconds": 300
}
```
`ack` — the server's acknowledgement of a client ack:
```json
{ "type": "ack", "eventId": "…" }
```
### Client → server frames
`ack` — "I have this event and I am ringing":
```json
{ "type": "ack", "eventId": "…" }
```
`ping` — application-level keepalive. The server answers with a WebSocket
**pong control frame** (there is no JSON pong) and refreshes the subscription's
`last_seen_at`.
```json
{ "type": "ping" }
```
Unknown or malformed client frames are ignored, never fatal.
**The one fatal exception is frame size.** A client frame larger than **4096
bytes** is a protocol violation: the server refuses it and closes the
connection with the WebSocket close code `1009` (message too big). `ack` and
`ping` are tiny, so this only bites a client that tries to send something else
through the socket — don't. If you see an unexplained disconnect, this is a
candidate; the same applies to sending non-JSON frames.
### Keepalive and timeouts
- The server sends a WebSocket ping every **20s**; a client that sends nothing
(no pong, no `ping`) for **60s** is dropped.
- Every write has a **10s** deadline, so a dead peer can never block the hub.
- Each connection has a buffered send queue. A client that cannot keep up is
**dropped** (its socket closed) rather than allowed to slow the fan-out down
for everyone else. Reconnect and the catch-up frame restores the state.
### Reconnect catch-up
The service keeps the **last event per alert** in memory. On connect, if that
event is still active (a `trigger` whose auto-stop window has not expired), the
server sends it immediately — so a client that reconnects mid-alarm fires
without polling, and a client that reconnects *after* the alarm is over is never
re-alarmed.
### Multiple instances
Fan-out is local: `Broadcast` writes to this instance's connections. Every
trigger/disarm is also an `INSERT` into `alarm.alarm_events`, whose trigger
issues `pg_notify('alarm_events', …)`; each instance holds a `LISTEN` and
re-broadcasts to its own clients. So a trigger raised on instance A fires on
subscribers of instance B as well. Duplicate deliveries are suppressed by
`eventId`, so the local fast path and the NOTIFY path cannot double-fire.
### Minimal client
```js
const ws = new WebSocket(
`ws://alarm:8088/ws/alarm?alertId=${alertId}&token=${subscribeToken}` +
`&accessToken=${accessToken}&deviceId=${deviceId}`);
ws.onmessage = (ev) => {
const f = JSON.parse(ev.data);
if (f.type === "alarm" && f.action === "trigger") {
// autoStopSeconds: 0 = ring until a disarm arrives; >0 = self-stop after N
ring(f.message, f.eventId, f.autoStopSeconds); // max volume, wake screen
ws.send(JSON.stringify({ type: "ack", eventId: f.eventId }));
}
if (f.type === "alarm" && f.action === "disarm") stopRinging(f.eventId);
};
setInterval(() => ws.readyState === 1 && ws.send('{"type":"ping"}'), 25000);
```
### Polling fallback
When the socket cannot be established, poll `alarmStatus` (every 5–15s while
the app is foregrounded) and treat `active: true` with a new `eventId` exactly
like a live `trigger` frame, and `active: false` (or `action: "disarm"`) as the
stop signal. Always prefer the socket: it is the < 1s path, and polling only
bounds the worst case.
---
## `GET /_manifest`
```json
{
"name": "Alarm",
"author": "Dcntrlized",
"capabilities": ["database", "alarm.live"],
"has_auth": true,
"public_key": "",
"icon": ""
}
```
Served verbatim, `Content-Type: application/json`. `database` declares the
schema/migration capability Maestro provisions; `has_auth` tells Maestro to
mount the shared-auth proxy.
---
## Worked example
```bash
BASE=http://localhost:8088
# 1. access token (shared auth)
TOKEN=$(curl -s -X POST "$BASE/auth/login" \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"admin_password_123"}' | jq -r .accessToken)
# 2. create the alert (keep adminToken!)
curl -s -X POST "$BASE/action/createAlert" -H "X-Access-Token: $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"payload":{"name":"Fire drill","message":"Leave the building","autoStopSeconds":300}}' | jq
# 3. a second user scans the QR payload and joins
curl -s -X POST "$BASE/action/joinAlert" -H "X-Access-Token: $BOB" \
-H 'Content-Type: application/json' \
-d '{"payload":{"alertId":"'$ALERT'","subscribeToken":"'$SUB'","deviceId":"pixel-7"}}' | jq
# 4. bob opens the socket (leave it running in another terminal)
websocat "ws://localhost:8088/ws/alarm?alertId=$ALERT&token=$SUB&accessToken=$BOB&deviceId=pixel-7"
# 5. trigger — bob's socket prints the alarm frame
curl -s -X POST "$BASE/action/triggerAlert" -H "X-Access-Token: $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"payload":{"alertId":"'$ALERT'","adminToken":"'$ADMIN'"}}' | jq
# 6. disarm
curl -s -X POST "$BASE/action/disarmAlert" -H "X-Access-Token: $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"payload":{"alertId":"'$ALERT'","adminToken":"'$ADMIN'"}}' | jq
```
## Storage
All state lives in the shared PostgreSQL database, schema `alarm`:
| Table | Contents |
| --- | --- |
| `alarm.alerts` | one row per alert: name, message, admin user id, admin-token hash, subscribe token + version, status, timestamps, `auto_stop_seconds` |
| `alarm.subscriptions` | `(alert, user, device)` unique, label/platform, `enabled`, `last_seen_at` |
| `alarm.alarm_events` | append-only trigger/disarm history; its `AFTER INSERT` trigger issues the `pg_notify` that fans out to other instances |
Migrations live in `database/migrations/` and are applied by the compose
`migrate` one-shot; they are idempotent and safe to re-apply.
+41
View File
@@ -0,0 +1,41 @@
# --- Build Stage ---
FROM golang:1.26 AS builder
WORKDIR /app
# Shared modules (git.dcntrlized.org/dcntrlized/dcntrlized-auth) are served by
# the dev Gitea Go module proxy. Override via --build-arg in production.
ARG GOPROXY_VALUE=http://192.168.122.183:3000/api/packages/Dcntrlized/go
ARG GONOSUMDB_VALUE=git.dcntrlized.org
ENV GOPROXY=${GOPROXY_VALUE},https://proxy.golang.org,direct
ENV GONOSUMDB=${GONOSUMDB_VALUE}
COPY src/go.mod src/go.sum ./
RUN go mod download
COPY src/ ./
# Build the whole main package (not just main.go): task-2/task-4 add files to
# package main (and any embed directive would need it), so a single-file build
# is not safe.
RUN CGO_ENABLED=0 GOOS=linux go build -o server .
# --- Run Stage ---
FROM alpine:latest
WORKDIR /root/
ARG IMAGE_VERSION=dev
ARG IMAGE_REVISION=
LABEL org.dcntrlized.service=alarm
LABEL org.dcntrlized.port=8080
LABEL org.opencontainers.image.version=$IMAGE_VERSION
LABEL org.opencontainers.image.revision=$IMAGE_REVISION
COPY --from=builder /app/server .
# 8080 is the service's ONLY port: the API (/_manifest, /auth/*, /action/*) and
# the /ws/alarm WebSocket share it, plaintext in-container (the operator's TLS
# proxy fronts alarm.dcntrlized.org in prod).
EXPOSE 8080
CMD ["./server"]
+70
View File
@@ -0,0 +1,70 @@
# Source: https://oneuptime.com/blog/post/2026-01-07-go-hot-reloading-docker-air/view#step-3-create-the-development-dockerfile
# Dockerfile (Development)
# This Dockerfile is optimized for development with hot reloading
# It includes Air and mounts your source code as a volume
# Use the official Go image as our base
# Alpine variant is smaller but we use the full image for better compatibility
FROM golang:1.26-bookworm
# Set the working directory inside the container
# All subsequent commands will run from this directory
WORKDIR /app
# Shared modules (git.dcntrlized.org/dcntrlized/dcntrlized-auth) are served by
# the dev Gitea Go module proxy. Override via --build-arg in production.
ARG GOPROXY_VALUE=http://192.168.122.183:3000/api/packages/Dcntrlized/go
ARG GONOSUMDB_VALUE=git.dcntrlized.org
ENV GOPROXY=${GOPROXY_VALUE},https://proxy.golang.org,direct
ENV GONOSUMDB=${GONOSUMDB_VALUE}
# Install Air for hot reloading
# We install it globally so it's available in the PATH
RUN go install github.com/air-verse/air@latest
# Install additional development tools (optional but useful)
# - delve: Go debugger for debugging inside containers
# - golangci-lint: Linter for code quality checks
RUN go install github.com/go-delve/delve/cmd/dlv@latest && \
curl -sSfL https://golangci-lint.run/install.sh | sh -s -- -b $(go env GOPATH)/bin v2.11.2
# Copy go.mod and go.sum first for better layer caching
# This layer only rebuilds when dependencies change
COPY src/go.mod src/go.sum* ./
# Download all dependencies
# This is cached separately from source code changes
RUN go mod download && go mod verify
# Copy the Air configuration file
# This tells Air how to watch and rebuild our application
COPY ./src/.air.toml ./
# Note: We do NOT copy source code here!
# Source code is mounted as a volume in Docker Compose
# This enables hot reloading without rebuilding the image
# Expose the application port
# Make sure this matches your application's listening port
EXPOSE 8080
# Expose delve debugger port (optional, for debugging)
EXPOSE 2345
# Set environment variables for development
ENV GO111MODULE=on
# Default command: run Air for hot reloading
# Air will watch for changes and rebuild automatically
CMD ["air", "-c", ".air.toml"]
##
ARG IMAGE_VERSION=dev
ARG IMAGE_REVISION=
LABEL org.dcntrlized.service=Alarm
LABEL org.dcntrlized.port=8080
LABEL org.opencontainers.image.version=$IMAGE_VERSION
LABEL org.opencontainers.image.revision=$IMAGE_REVISION
Executable
+29
View File
@@ -0,0 +1,29 @@
#!/bin/env bash
# Copyright (C) 2026 Dcntrlized
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
set -euo pipefail
IMAGE_NAME="alarm"
DOCKERFILE="Dockerfile"
if [[ "${1:-}" == "--debug" ]]; then
DOCKERFILE="Dockerfile.debug"
IMAGE_NAME="alarm:debug"
fi
VERSION="$(git describe --tags --abbrev=0 2>/dev/null || echo dev)"
REVISION="$(git rev-parse --short HEAD 2>/dev/null || echo dev)"
docker build --network=host -t "$IMAGE_NAME" -f "$DOCKERFILE" --build-arg IMAGE_VERSION="$VERSION" --build-arg IMAGE_REVISION="$REVISION" .
+187
View File
@@ -0,0 +1,187 @@
\c dcntrlized;
-- ============================================================================
-- Alarm service v1 schema (contract frozen 2026-10-18).
--
-- Identity comes from the shared dcntrlized-auth service: every *_user_id
-- column is a plain UUID pointing at auth.users(id) -- NO foreign key, so the
-- alarm schema never depends on the auth service's internals (platform
-- convention, see Calendar/KeepNote).
--
-- The whole file is idempotent: every statement is guarded, so the compose
-- `migrate` one-shot can re-apply it against an already-migrated database.
-- ============================================================================
CREATE SCHEMA IF NOT EXISTS alarm;
-- ---------------------------------------------------------------------------
-- Guarded enum types. CREATE TYPE has no IF NOT EXISTS, so each one is wrapped
-- in a DO block that checks pg_type/pg_namespace first.
-- ---------------------------------------------------------------------------
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_type t
JOIN pg_namespace n ON n.oid = t.typnamespace
WHERE t.typname = 'alert_status' AND n.nspname = 'alarm'
) THEN
CREATE TYPE alarm.alert_status AS ENUM ('armed', 'triggered', 'disarmed');
END IF;
END
$$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_type t
JOIN pg_namespace n ON n.oid = t.typnamespace
WHERE t.typname = 'event_kind' AND n.nspname = 'alarm'
) THEN
CREATE TYPE alarm.event_kind AS ENUM ('trigger', 'disarm');
END IF;
END
$$;
-- ---------------------------------------------------------------------------
-- alerts: one row per alert created by an admin (user A).
--
-- admin_token_hash sha256(adminToken); the plaintext admin token is returned
-- exactly once at creation and never stored.
-- subscribe_token stored PLAINTEXT on purpose: it is shared through the QR
-- code, and rotating it is a cheap UPDATE (token_version).
-- status last COMMANDED state. `active` is derived, never stored:
-- active = status = 'triggered' AND (auto_stop_seconds = 0
-- OR now() < triggered_at + auto_stop_seconds).
-- Deriving it keeps auto-stop stateless and multi-instance
-- safe (no timers, no background sweep).
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS alarm.alerts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
admin_user_id UUID NOT NULL,
name VARCHAR(255) NOT NULL,
message TEXT,
status alarm.alert_status NOT NULL DEFAULT 'armed',
admin_token_hash BYTEA NOT NULL,
subscribe_token VARCHAR(128) NOT NULL,
token_version INTEGER NOT NULL DEFAULT 1,
auto_stop_seconds INTEGER NOT NULL DEFAULT 0,
created TIMESTAMPTZ NOT NULL DEFAULT NOW(),
modified TIMESTAMPTZ NOT NULL DEFAULT NOW(),
triggered_at TIMESTAMPTZ,
disarmed_at TIMESTAMPTZ,
CONSTRAINT alerts_auto_stop_seconds_range CHECK (auto_stop_seconds >= 0 AND auto_stop_seconds <= 86400),
CONSTRAINT alerts_token_version_positive CHECK (token_version >= 1)
);
-- ---------------------------------------------------------------------------
-- subscriptions: one row per (alert, subscriber user, device). A user may
-- subscribe several devices; the same device is idempotent.
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS alarm.subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
alert_id UUID NOT NULL REFERENCES alarm.alerts(id) ON DELETE CASCADE,
subscriber_user_id UUID NOT NULL,
device_id VARCHAR(128) NOT NULL,
device_label VARCHAR(128),
platform VARCHAR(32),
enabled BOOLEAN NOT NULL DEFAULT TRUE,
last_seen_at TIMESTAMPTZ,
created TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT subscriptions_alert_user_device_key UNIQUE (alert_id, subscriber_user_id, device_id)
);
-- ---------------------------------------------------------------------------
-- alarm_events: append-only trigger/disarm log. It is the source of truth for
-- alarmStatus (the polling fallback) and for the WS catch-up frame.
--
-- The AFTER INSERT trigger publishes the event on the `alarm_events` NOTIFY
-- channel, so the fan-out reaches EVERY service instance. NOTIFY is delivered
-- on COMMIT, so a frame can never be observed before the alert state change is
-- visible.
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS alarm.alarm_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
alert_id UUID NOT NULL REFERENCES alarm.alerts(id) ON DELETE CASCADE,
kind alarm.event_kind NOT NULL,
actor_user_id UUID NOT NULL,
message TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ---------------------------------------------------------------------------
-- Indexes. idx_alarm_events_latest backs the alarmStatus single-row lookup
-- (alert_id, newest first); the others back the ownership/subscriber checks.
-- ---------------------------------------------------------------------------
CREATE INDEX IF NOT EXISTS idx_alerts_admin_user_id ON alarm.alerts(admin_user_id);
CREATE INDEX IF NOT EXISTS idx_subscriptions_alert_id ON alarm.subscriptions(alert_id);
CREATE INDEX IF NOT EXISTS idx_subscriptions_user_id ON alarm.subscriptions(subscriber_user_id);
CREATE INDEX IF NOT EXISTS idx_alarm_events_alert_id ON alarm.alarm_events(alert_id);
CREATE INDEX IF NOT EXISTS idx_alarm_events_latest ON alarm.alarm_events(alert_id, created_at DESC, id DESC);
-- ---------------------------------------------------------------------------
-- modified maintenance for alerts.
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION alarm.update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.modified = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS update_alerts_modtime ON alarm.alerts;
CREATE TRIGGER update_alerts_modtime
BEFORE UPDATE ON alarm.alerts
FOR EACH ROW
EXECUTE FUNCTION alarm.update_updated_at_column();
-- ---------------------------------------------------------------------------
-- Cross-instance fan-out. The payload is the transport form of the WS frame:
-- the bridge formats serverTime from createdAtMs, so the in-process fast path
-- and the NOTIFY path agree on the wire shape.
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION alarm.notify_alarm_event()
RETURNS TRIGGER AS $$
DECLARE
auto_stop INTEGER;
BEGIN
SELECT a.auto_stop_seconds INTO auto_stop FROM alarm.alerts a WHERE a.id = NEW.alert_id;
PERFORM pg_notify('alarm_events', json_build_object(
'alertId', NEW.alert_id::text,
'eventId', NEW.id::text,
'action', NEW.kind::text,
'message', COALESCE(NEW.message, ''),
'autoStopSeconds', COALESCE(auto_stop, 0),
'createdAtMs', (EXTRACT(EPOCH FROM NEW.created_at) * 1000)::bigint
)::text);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS notify_alarm_event ON alarm.alarm_events;
CREATE TRIGGER notify_alarm_event
AFTER INSERT ON alarm.alarm_events
FOR EACH ROW
EXECUTE FUNCTION alarm.notify_alarm_event();
-- ---------------------------------------------------------------------------
-- Least-privilege role, mirroring the Calendar/KeepNote house pattern. The
-- shared database currently authenticates as the shared `postgres` role
-- (contract rev 9), so these grants are inert-but-ready; they are idempotent
-- and safe to re-run.
-- ---------------------------------------------------------------------------
DO $$
BEGIN
IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'alarm_user') THEN
CREATE USER alarm_user WITH PASSWORD 'alarm';
END IF;
END
$$;
GRANT CONNECT ON DATABASE dcntrlized TO alarm_user;
GRANT USAGE ON SCHEMA alarm TO alarm_user;
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA alarm TO alarm_user;
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA alarm TO alarm_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA alarm GRANT ALL ON TABLES TO alarm_user;
+4
View File
@@ -0,0 +1,4 @@
# Dependencies for the database/seeds/*.py scripts. Installed by the
# compose 'seed' service (and used for host-side seeding via setup.py).
psycopg2-binary
bcrypt
+43
View File
@@ -0,0 +1,43 @@
# Copyright (C) 2026 Dcntrlized
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
Example seed for the shared dcntrlized database.
Seeds run automatically after migrations complete, via the compose 'seed'
service: database/seeds is mounted read-only into a python container and every
*.py file here is executed in sorted order. Connection settings come from the
PGHOST/PGPORT/PGUSER/PGPASSWORD/PGDATABASE environment variables set by that
service (psycopg2.connect() reads them implicitly).
Keep every seed idempotent: it re-runs on every `docker compose up` (use
ON CONFLICT DO NOTHING / IF NOT EXISTS style upserts, stable uuids, ...).
"""
import psycopg2
def main():
conn = psycopg2.connect()
cur = conn.cursor()
cur.execute("SELECT 1")
cur.fetchone()
cur.close()
conn.close()
print("001_seed_example.py: no-op seed. Replace with real seeds.")
if __name__ == "__main__":
main()
+77
View File
@@ -0,0 +1,77 @@
<!-- Copyright (C) 2026 Dcntrlized
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Dev</title>
<link rel="stylesheet" href="main.css" />
</head>
<body>
<script>
function handleFormSubmit(event) {
event.preventDefault();
const form = event.target;
const formData = new FormData(form);
const data = Object.fromEntries(formData.entries());
fetch(form.action, {
method: form.method,
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
})
.then((response) => response.json())
.then((data) => {
console.log("Success: " + JSON.stringify(data));
})
.catch((error) => {
console.error("Error:", error);
});
}
</script>
<form
id="login"
action="http://127.0.0.1:8080/auth/login"
method="post"
onsubmit="handleFormSubmit(event)"
>
<label for="username">Username:</label>
<input type="text" id="username" name="username" required />
<br />
<label for="password">Password:</label>
<input type="password" id="password" name="password" required />
<br />
<button type="submit">Login</button>
</form>
<form
id="register"
action="http://127.0.0.1:8080/auth/register"
method="post"
onsubmit="handleFormSubmit(event)"
>
<label for="username">Username:</label>
<input type="text" id="username" name="username" required />
<br />
<label for="password">Password:</label>
<input type="password" id="password" name="password" required />
<br />
<button type="submit">Register</button>
</form>
</body>
</html>
+119
View File
@@ -0,0 +1,119 @@
# Source: https://oneuptime.com/blog/post/2026-01-07-go-hot-reloading-docker-air/view#step-4-set-up-docker-compose
# docker-compose.yml
# Development environment with hot reloading
services:
# Main Go application with Air hot reloading
alarm-debug:
extra_hosts:
- "host.docker.internal:host-gateway"
image: alarm:debug
container_name: alarm-debug
# Cold `docker compose -f docker-compose.debug.yaml up -d` builds this image
# directly — no pre-build step needed. Do NOT add `network: host` here:
# `docker compose up`/`build` cannot grant the `network.host` build
# entitlement (that needs `--allow=network.host`, which only `docker build`
# accepts), so a cold up would fail with "additional privileges requested".
# The default bridge reaches the dev Gitea (192.168.122.183), so
# Dockerfile.debug's GOPROXY resolves shared modules without host networking.
build:
context: .
dockerfile: Dockerfile.debug
# `alarm:debug` is a local-only tag: build it, never look it up in a
# registry (otherwise a cold `up` warns "pull access denied").
pull_policy: build
# Expose ports: host:container
ports:
# Frozen v1 contract: host 8088 -> container 8080 (single port: API +
# /ws/alarm).
- "8088:8080"
# Delve: unique host port per service (auth-debug 2345, keepnote-debug
# 2346, calendar-debug 2347, store 2348), so the Go attach launch config
# reaches Air's Delve listener (in-container 2345) from the host.
- "2349:2345" # Delve debugger port
# Volume mounts - THE KEY TO HOT RELOADING
# Mount source code as a volume so changes are reflected immediately
volumes:
- ./src:/app
- go-mod-cache:/go/pkg/mod
- go-build-cache:/root/.cache/go-build
- /app/src/tmp # Exclude Air's tmp dir from syncing
working_dir: /app/
# Environment variables for the application
environment:
# DCNTRLIZED_DEV is the single dev selector (registry/auth URLs).
- DCNTRLIZED_DEV=true
# Contract rev 9: internal traffic is plaintext, so the debug selector no
# longer gates any listener — this listener is ALWAYS plaintext now.
- DCNTRLIZED_DEBUG=true
# Shared auth service target (dcntrlized-auth on the dcntrlized network)
- AUTH_SERVICE_URL=http://dcntrlized-auth:8080
# Service identity (inert in v1 — no /config edge — kept for fleet parity)
- DCNTRLIZED_SERVICE_NAME=alarm
# Database connection (shared dcntrlized-db, scram-sha-256, sslmode=disable)
- DATABASE_URL=postgres://postgres:dcntrlized@dcntrlized-db:5432/dcntrlized?sslmode=disable
# Go specific settings
- GO111MODULE=on
- GOPROXY=http://192.168.122.183:3000/api/packages/Dcntrlized/go,https://proxy.golang.org,direct
- GONOSUMDB=git.dcntrlized.org
depends_on:
- migrate
# Restart policy for development
restart: unless-stopped
# Network for service communication
networks:
- dcntrlized_net
cap_add:
- SYS_PTRACE
security_opt:
- apparmor:unconfined
migrate:
image: postgres:16-alpine
container_name: alarm-migrate
labels:
org.dcntrlized.oneoff: "true"
networks:
- dcntrlized_net
volumes:
- ./database/migrations:/migrations:ro
environment:
- PGHOST=dcntrlized-db
- PGPORT=5432
- PGUSER=postgres
- PGPASSWORD=
- PGDATABASE=dcntrlized
# Role-agnostic (no -U/-d): Maestro injects the shared credentials/env.
command: >
sh -c 'until pg_isready -h dcntrlized-db; do sleep 2; done;
for f in /migrations/*.sql; do [ -f "$$f" ] || continue; echo "Applying $$f"; psql -v ON_ERROR_STOP=1 -f "$$f" || exit 1; done'
# Named volumes for data persistence and caching
volumes:
# Go module cache - shared across rebuilds
go-mod-cache:
driver: local
# Go build cache - speeds up compilation
go-build-cache:
driver: local
networks:
dcntrlized_net:
external: true
+51
View File
@@ -0,0 +1,51 @@
services:
alarm-service:
extra_hosts:
- "host.docker.internal:host-gateway"
# Deliberately no build: block — compose-from-git pulls this image
# (Calendar still carries one; do not copy it here). A prod compose that
# can build invites a silent local build when the registry pull fails.
image: 192.168.122.183:3000/dcntrlized/alarm:dev
container_name: alarm
networks:
- dcntrlized_net
expose:
# Single port: the API (/_manifest, /auth/*, /action/*) and the
# /ws/alarm WebSocket all share :8080. Expose-only so the operator's TLS
# proxy (NPM-private) can front it on alarm.dcntrlized.org without a
# host port.
- "8080"
ports:
# Frozen v1 contract: host 8088 -> container 8080.
- "8088:8080"
environment:
- DATABASE_URL=postgres://postgres:dcntrlized@dcntrlized-db:5432/dcntrlized?sslmode=disable
- AUTH_SERVICE_URL=http://dcntrlized-auth:8080
# Service identity (same value Maestro injects for the /config ES256
# token audience in Calendar/KeepNote). Alarm mounts no /config edge in
# v1, so this is inert today and correct if one is added later.
- DCNTRLIZED_SERVICE_NAME=alarm
depends_on:
- migrate
migrate:
image: postgres:16-alpine
container_name: alarm-migrate
labels:
org.dcntrlized.oneoff: "true"
networks:
- dcntrlized_net
volumes:
- ./database/migrations:/migrations:ro
environment:
- PGHOST=dcntrlized-db
- PGPORT=5432
- PGUSER=postgres
- PGPASSWORD=
- PGDATABASE=dcntrlized
command: >
sh -c 'until pg_isready -h dcntrlized-db; do sleep 2; done;
for f in /migrations/*.sql; do [ -f "$$f" ] || continue; echo "Applying $$f"; psql -v ON_ERROR_STOP=1 -f "$$f" || exit 1; done'
networks:
dcntrlized_net:
external: true
+144
View File
@@ -0,0 +1,144 @@
# Source: https://oneuptime.com/blog/post/2026-01-07-go-hot-reloading-docker-air/view#step-2-install-and-configure-air
# .air.toml - Air Configuration for Hot Reloading
# This file configures how Air watches and rebuilds your Go application
# Root directory for watching - use "." for current directory
root = "."
# Temporary directory where Air stores build artifacts
# This directory is created automatically and should be gitignored
tmp_dir = "tmp"
[build]
# Command to build your application
# Add any build flags you need here (like -race for race detection)
cmd = "go build -gcflags='all=-N -l' -o ./tmp/main ."
# Tell Air to launch the compiled binary inside Delve using the legacy API
full_bin = "dlv exec --accept-multiclient --headless --continue --listen=:2345 --api-version=2 ./tmp/main"
# Path to the built binary that Air will execute
# bin = "./tmp/main"
# Entrypoint binary relative to root
# Air recommends this over relying on bin alone
# entrypoint = ["./tmp/main"]
# Arguments to pass to your application when running
# Useful for passing flags or configuration
args_bin = []
# Pre-build command - runs before each build
# Useful for code generation, proto compilation, etc.
pre_cmd = []
# Post-build command - runs after each successful build
# Useful for running migrations or other setup tasks
post_cmd = []
# Enable polling mode for file watching (useful for Docker on some OSes)
# Set to true if you're on Windows or using certain Docker setups
poll = false
# Polling interval in milliseconds (only used if poll = true)
poll_interval = 500
# Build delay after file change is detected (in milliseconds)
# Helps batch multiple rapid changes into one rebuild
delay = 1000
# Stop running the old binary before building the new one
# Recommended to avoid port conflicts
stop_on_error = true
# Send interrupt signal before killing process
# Allows graceful shutdown of your application
send_interrupt = true
# Delay after interrupt before force killing
kill_delay = "500ms"
# Rerun binary on build error - useful for debugging
rerun = false
# Delay before rerunning (in milliseconds)
rerun_delay = 500
# Build error log file, placed inside tmp_dir
log = "build-errors.log"
# Directories to watch for changes
# Air watches the whole root unless you restrict it here
include_dir = []
# Directories to exclude from watching
# These directories will be ignored when detecting file changes
exclude_dir = [
"tmp",
"vendor",
"node_modules",
".git",
"testdata",
"docs",
"scripts",
]
# File patterns to exclude from watching
# Useful for ignoring generated files, tests, or documentation
exclude_regex = [
"_test\\.go$",
"_mock\\.go$",
"\\.pb\\.go$",
"swagger\\.json$",
"\\.md$",
]
# File extensions to watch - only these file types trigger rebuilds
# Add more extensions if you have other file types that should trigger rebuilds
include_ext = ["go", "tpl", "tmpl", "html", "env"]
# Specific files to exclude from watching
exclude_file = [
"go.sum",
".air.toml",
]
# Specific files to watch (in addition to extension-based matching)
include_file = []
# Exclude unchanged files from triggering rebuilds
exclude_unchanged = false
[color]
# Customize output colors for better readability in terminal
main = "magenta"
watcher = "cyan"
build = "yellow"
runner = "green"
[log]
# Log output settings
# Time format for logs (Go time format)
time = true
# Only show main binary output (hide Air's internal logs)
main_only = false
[misc]
# Clean temporary directory on exit
clean_on_exit = true
[screen]
# Clear screen on rebuild for cleaner output
clear_on_rebuild = true
# Keep scroll position (set to false for always scrolling to top)
keep_scroll = true
[proxy]
# Proxy configuration (optional, for advanced setups)
enabled = false
proxy_port = 8090
app_port = 8080
+388
View File
@@ -0,0 +1,388 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package actions
import (
"encoding/json"
"net/http"
"strings"
"time"
"git.dcntrlized.org/Dcntrlized/Alarm/db"
"git.dcntrlized.org/Dcntrlized/Alarm/internal/alarmtoken"
"git.dcntrlized.org/Dcntrlized/Alarm/internal/alertstate"
"git.dcntrlized.org/Dcntrlized/Alarm/internal/qrpayload"
"git.dcntrlized.org/dcntrlized/dcntrlized-auth/authclient"
"github.com/google/uuid"
)
// ---- gates ------------------------------------------------------------------
// requireOwner verifies the access token, resolves the alert and checks that
// the caller is its admin. It writes the refusal itself.
func requireOwner(w http.ResponseWriter, r *http.Request, action string, payload any) (*db.Alert, *authclient.Principal, bool) {
p, ok := principal(w, r, action)
if !ok {
return nil, nil, false
}
alertID, err := alertIDFrom(r, payload)
if err != nil {
validationError(w, "%v", err)
return nil, nil, false
}
alert, err := db.GetAlert(r.Context(), alertID)
if err != nil {
dbError(w, action, err)
return nil, nil, false
}
if alert.AdminUserID != p.ID {
Warn(action, "auth", "user=%s is not the admin of alert=%s", p.ID, alert.ID)
writeError(w, http.StatusForbidden, CodeAlertNotAdmin, "caller is not the alert admin")
return nil, nil, false
}
return alert, p, true
}
// requireAdminToken is requireOwner plus verification of the alert's admin
// token (the second factor for live and destructive commands).
func requireAdminToken(w http.ResponseWriter, r *http.Request, action string, payload any) (*db.Alert, *authclient.Principal, bool) {
alert, p, ok := requireOwner(w, r, action, payload)
if !ok {
return nil, nil, false
}
token := adminTokenFrom(r, payload)
if token == "" {
writeError(w, http.StatusForbidden, CodeAlertTokenInvalid, "adminToken is required")
return nil, nil, false
}
if !alarmtoken.EqualHash(alert.AdminTokenHash, token) {
Warn(action, "auth", "admin token mismatch alert=%s", alert.ID)
writeError(w, http.StatusForbidden, CodeAlertTokenInvalid, "invalid admin token")
return nil, nil, false
}
return alert, p, true
}
// isAdminOrSubscriber is the read gate for getAlert/alarmStatus/qrImage.
func isAdminOrSubscriber(r *http.Request, alert *db.Alert, userID uuid.UUID) (bool, error) {
if alert.AdminUserID == userID {
return true, nil
}
return db.IsSubscriber(r.Context(), alert.ID, userID)
}
// ---- payload extraction -----------------------------------------------------
// lenientString pulls one string field out of an arbitrary payload object
// without rejecting the other fields. It is used for the credentials that ride
// the payload alongside the action's own arguments.
func lenientString(payload any, field string) string {
if payload == nil {
return ""
}
raw, err := json.Marshal(payload)
if err != nil {
return ""
}
var m map[string]any
if err := json.Unmarshal(raw, &m); err != nil {
return ""
}
s, _ := m[field].(string)
return strings.TrimSpace(s)
}
func adminTokenFrom(r *http.Request, payload any) string {
if v := strings.TrimSpace(r.URL.Query().Get("adminToken")); v != "" {
return v
}
return lenientString(payload, "adminToken")
}
func deref(s *string) string {
if s == nil {
return ""
}
return *s
}
// ---- views ------------------------------------------------------------------
type eventView struct {
EventID string `json:"eventId"`
Action string `json:"action"`
Message string `json:"message"`
ServerTime string `json:"serverTime"`
}
type alertView struct {
AlertID string `json:"alertId"`
Name string `json:"name"`
Message string `json:"message"`
Status string `json:"status"`
Active bool `json:"active"`
AutoStopSeconds int `json:"autoStopSeconds"`
TokenVersion int `json:"tokenVersion"`
Created string `json:"created"`
Modified string `json:"modified"`
TriggeredAt *string `json:"triggeredAt,omitempty"`
DisarmedAt *string `json:"disarmedAt,omitempty"`
QRPayload string `json:"qrPayload"`
IsAdmin bool `json:"isAdmin"`
SubscriptionCount int `json:"subscriptionCount"`
LastEvent *eventView `json:"lastEvent,omitempty"`
}
func newAlertView(a *db.Alert, isAdmin bool, count int, ev *db.Event, now time.Time) alertView {
v := alertView{
AlertID: a.ID.String(),
Name: a.Name,
Message: deref(a.Message),
Status: a.Status,
Active: alertstate.IsActive(a.Status, a.TriggeredAt, a.AutoStopSeconds, now),
AutoStopSeconds: a.AutoStopSeconds,
TokenVersion: a.TokenVersion,
Created: rfc3339(a.Created),
Modified: rfc3339(a.Modified),
TriggeredAt: rfc3339Ptr(a.TriggeredAt),
DisarmedAt: rfc3339Ptr(a.DisarmedAt),
QRPayload: qrpayload.Build(a.ID, a.SubscribeToken),
IsAdmin: isAdmin,
SubscriptionCount: count,
}
if ev != nil {
v.LastEvent = &eventView{
EventID: ev.ID.String(),
Action: ev.Kind,
Message: deref(ev.Message),
ServerTime: rfc3339(ev.CreatedAt),
}
}
return v
}
// ---- createAlert ------------------------------------------------------------
type createAlertRequest struct {
Name string `json:"name"`
Message string `json:"message"`
AutoStopSeconds *int `json:"autoStopSeconds"`
}
type createAlertResponse struct {
AlertID string `json:"alertId"`
Name string `json:"name"`
Message string `json:"message"`
Status string `json:"status"`
Active bool `json:"active"`
AdminToken string `json:"adminToken"`
SubscribeToken string `json:"subscribeToken"`
QRPayload string `json:"qrPayload"`
AutoStopSeconds int `json:"autoStopSeconds"`
TokenVersion int `json:"tokenVersion"`
Created string `json:"created"`
}
// CreateAlert implements POST /action/createAlert.
//
// The admin token is generated here, returned in this response and never
// stored: only its SHA-256 digest reaches the database.
func CreateAlert(w http.ResponseWriter, r *http.Request, payload any) {
const action = "createAlert"
p, ok := principal(w, r, action)
if !ok {
return
}
var req createAlertRequest
if err := decodePayload(payload, &req); err != nil {
validationError(w, "invalid payload: %v", err)
return
}
name, err := validateName(req.Name)
if err != nil {
validationError(w, "%v", err)
return
}
message, err := validateMessage(req.Message)
if err != nil {
validationError(w, "%v", err)
return
}
autoStop := 0
if req.AutoStopSeconds != nil {
autoStop = *req.AutoStopSeconds
}
if err := validateAutoStop(autoStop); err != nil {
validationError(w, "%v", err)
return
}
adminToken, err := alarmtoken.NewAdminToken()
if err != nil {
Error(action, "random", "%v", err)
writeError(w, http.StatusInternalServerError, CodeInternal, "could not generate credentials")
return
}
subscribeToken, err := alarmtoken.NewSubscribeToken()
if err != nil {
Error(action, "random", "%v", err)
writeError(w, http.StatusInternalServerError, CodeInternal, "could not generate credentials")
return
}
var msgPtr *string
if strings.TrimSpace(message) != "" {
msgPtr = &message
}
alert, err := db.CreateAlert(r.Context(), &db.Alert{
AdminUserID: p.ID,
Name: name,
Message: msgPtr,
AdminTokenHash: alarmtoken.Hash(adminToken),
SubscribeToken: subscribeToken,
AutoStopSeconds: autoStop,
})
if err != nil {
dbError(w, action, err)
return
}
Debug(action, "POST", "alert=%s admin=%s autoStop=%d", alert.ID, p.ID, autoStop)
writeJSON(w, http.StatusOK, createAlertResponse{
AlertID: alert.ID.String(),
Name: alert.Name,
Message: deref(alert.Message),
Status: alert.Status,
Active: false,
AdminToken: adminToken,
SubscribeToken: alert.SubscribeToken,
QRPayload: qrpayload.Build(alert.ID, alert.SubscribeToken),
AutoStopSeconds: alert.AutoStopSeconds,
TokenVersion: alert.TokenVersion,
Created: rfc3339(alert.Created),
})
}
// ---- getAlert ---------------------------------------------------------------
// GetAlert implements GET/POST /action/getAlert. It is readable by the alert's
// admin and by any subscribed user.
func GetAlert(w http.ResponseWriter, r *http.Request, payload any) {
const action = "getAlert"
p, ok := principal(w, r, action)
if !ok {
return
}
alertID, err := alertIDFrom(r, payload)
if err != nil {
validationError(w, "%v", err)
return
}
res, err := db.GetAlertWithLatestEvent(r.Context(), alertID)
if err != nil {
dbError(w, action, err)
return
}
isAdmin := res.Alert.AdminUserID == p.ID
if !isAdmin {
allowed, err := db.IsSubscriber(r.Context(), alertID, p.ID)
if err != nil {
dbError(w, action, err)
return
}
if !allowed {
writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "not subscribed to this alert")
return
}
}
count, err := db.CountSubscriptions(r.Context(), alertID)
if err != nil {
dbError(w, action, err)
return
}
writeJSON(w, http.StatusOK, newAlertView(res.Alert, isAdmin, count, res.Event, time.Now().UTC()))
}
// ---- deleteAlert ------------------------------------------------------------
// DeleteAlert implements POST/DELETE /action/deleteAlert. Subscriptions and
// events cascade. A live alarm must be disarmed first: deleting it would leave
// every ringing client with no way to learn the alarm is over.
func DeleteAlert(w http.ResponseWriter, r *http.Request, payload any) {
const action = "deleteAlert"
alert, _, ok := requireAdminToken(w, r, action, payload)
if !ok {
return
}
now := time.Now().UTC()
if !alertstate.CanDelete(alertstate.IsActive(alert.Status, alert.TriggeredAt, alert.AutoStopSeconds, now)) {
writeError(w, http.StatusConflict, CodeAlertState, "disarm the alert before deleting it")
return
}
if err := db.DeleteAlert(r.Context(), alert.ID); err != nil {
dbError(w, action, err)
return
}
Debug(action, "POST", "alert=%s deleted", alert.ID)
writeJSON(w, http.StatusOK, map[string]any{
"alertId": alert.ID.String(),
"deleted": true,
})
}
// ---- rotateSubscribeToken ---------------------------------------------------
type rotateResponse struct {
AlertID string `json:"alertId"`
SubscribeToken string `json:"subscribeToken"`
QRPayload string `json:"qrPayload"`
TokenVersion int `json:"tokenVersion"`
}
// RotateSubscribeToken implements POST /action/rotateSubscribeToken. Existing
// subscriptions survive: rotation invalidates the QR/join path, not the people
// who already joined.
func RotateSubscribeToken(w http.ResponseWriter, r *http.Request, payload any) {
const action = "rotateSubscribeToken"
alert, _, ok := requireAdminToken(w, r, action, payload)
if !ok {
return
}
token, err := alarmtoken.NewSubscribeToken()
if err != nil {
Error(action, "random", "%v", err)
writeError(w, http.StatusInternalServerError, CodeInternal, "could not generate a token")
return
}
version, err := db.RotateSubscribeToken(r.Context(), alert.ID, token)
if err != nil {
dbError(w, action, err)
return
}
Debug(action, "POST", "alert=%s token rotated to version %d", alert.ID, version)
writeJSON(w, http.StatusOK, rotateResponse{
AlertID: alert.ID.String(),
SubscribeToken: token,
QRPayload: qrpayload.Build(alert.ID, token),
TokenVersion: version,
})
}
+63
View File
@@ -0,0 +1,63 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package actions
import (
"net/http"
"git.dcntrlized.org/dcntrlized/dcntrlized-auth/authproxy"
)
// Login proxies the Maestro-mode action to /auth/login. The {payload} envelope
// was already unwrapped by the dispatcher; the inner body is forwarded raw and
// the auth response is relayed unwrapped.
func Login(w http.ResponseWriter, r *http.Request, payload interface{}) {
authproxy.ProxyPayload(w, r, "login", payload)
}
// Register proxies the Maestro-mode action to /auth/register.
func Register(w http.ResponseWriter, r *http.Request, payload interface{}) {
authproxy.ProxyPayload(w, r, "register", payload)
}
// Logout proxies the logout action: POST forwards {refreshToken} so the auth
// service revokes the session (M5 client logout); GET stays a bodyless no-op
// for the legacy transition.
func Logout(w http.ResponseWriter, r *http.Request, payload interface{}) {
if r.Method == http.MethodPost {
authproxy.ProxyPayload(w, r, "logout", payload)
return
}
authproxy.ProxyEmpty(w, r, "logout")
}
// Me proxies the GET action variant to /auth/me. The X-Access-Token header is
// forwarded unchanged so the auth service resolves the caller (boot
// revalidation, M5).
func Me(w http.ResponseWriter, r *http.Request) {
authproxy.Proxy(w, r, "me")
}
// Refresh proxies the Maestro-mode refresh action (M5 auto-refresh path).
func Refresh(w http.ResponseWriter, r *http.Request, payload interface{}) {
authproxy.ProxyPayload(w, r, "refresh", payload)
}
// Delete proxies the Maestro-mode delete action to /auth/delete; the payload
// carries the password only and the X-Access-Token header is forwarded.
func Delete(w http.ResponseWriter, r *http.Request, payload interface{}) {
authproxy.ProxyPayload(w, r, "delete", payload)
}
+234
View File
@@ -0,0 +1,234 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// Package actions implements the Alarm service's /action/* surface.
//
// Every handler follows the same shape: verify the caller's X-Access-Token
// (authclient.Authorize), validate the {payload} envelope, do the work through
// the db package, and answer with a plain JSON object. Failures use the
// platform envelope {"error":{"code","message"}} with the frozen v1 codes.
package actions
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"git.dcntrlized.org/Dcntrlized/Alarm/db"
"git.dcntrlized.org/Dcntrlized/Alarm/internal/alertstate"
"git.dcntrlized.org/Dcntrlized/Alarm/internal/apierr"
"git.dcntrlized.org/dcntrlized/dcntrlized-auth/authclient"
"github.com/google/uuid"
)
// Frozen v1 error codes, aliased from internal/apierr so the WebSocket layer
// (which cannot import this package) uses the same strings.
const (
CodeAlertNotFound = apierr.AlertNotFound
CodeAlertTokenInvalid = apierr.AlertTokenInvalid
CodeAlertNotAdmin = apierr.AlertNotAdmin
CodeAlertAlreadyJoined = apierr.AlertAlreadyJoined
CodeAlertNotSubscribed = apierr.AlertNotSubscribed
CodeAlertState = apierr.AlertState
CodeValidation = apierr.Validation
CodeUnauthorized = apierr.Unauthorized
CodeInternal = apierr.Internal
)
// maxMessageBytes / maxNameChars bound the free-text fields.
const (
maxNameChars = 255
maxMessageBytes = 2000
maxDeviceIDLen = 128
maxLabelLen = 128
maxPlatformLen = 32
)
// errorEnvelope is the platform error body.
type errorEnvelope struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
// writeError emits {"error":{"code","message"}}.
func writeError(w http.ResponseWriter, status int, code, message string) {
var body errorEnvelope
body.Error.Code = code
body.Error.Message = message
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
}
// writeJSON emits a success body.
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
// validationError reports a client-side input problem.
func validationError(w http.ResponseWriter, format string, v ...any) {
writeError(w, http.StatusBadRequest, CodeValidation, fmt.Sprintf(format, v...))
}
// dbError maps the db package's sentinels onto wire codes. It returns true when
// it wrote a response.
func dbError(w http.ResponseWriter, action string, err error) bool {
switch {
case errors.Is(err, db.ErrNotFound):
writeError(w, http.StatusNotFound, CodeAlertNotFound, "alert not found")
case errors.Is(err, db.ErrNotAdmin):
writeError(w, http.StatusForbidden, CodeAlertNotAdmin, "caller is not the alert admin")
case errors.Is(err, db.ErrState):
writeError(w, http.StatusConflict, CodeAlertState, "operation not allowed in the current alert state")
default:
Error(action, "error", "%v", err)
writeError(w, http.StatusInternalServerError, CodeInternal, "internal error")
}
return true
}
// principal verifies the X-Access-Token header. On failure it writes the 401
// envelope and returns false.
func principal(w http.ResponseWriter, r *http.Request, action string) (*authclient.Principal, bool) {
p, err := authclient.Authorize(r)
if err != nil {
Warn(action, "auth", "rejected: %v", err)
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "missing or invalid access token")
return nil, false
}
return p, true
}
// decodePayload unmarshals the {payload} wrapper's inner value into dst.
//
// Unknown fields are deliberately ignored: the clients are shipped separately
// from the service, so a newer client sending an extra field must not be
// rejected with a 400 by an older server.
func decodePayload(payload any, dst any) error {
if payload == nil {
return errors.New("missing payload")
}
raw, err := json.Marshal(payload)
if err != nil {
return err
}
return json.NewDecoder(bytes.NewReader(raw)).Decode(dst)
}
// alertIDFrom extracts an alert id from the query string (GET form) or from the
// action payload (POST form). Accepts `alertId` and the shorthand `id`.
func alertIDFrom(r *http.Request, payload any) (uuid.UUID, error) {
raw := strings.TrimSpace(r.URL.Query().Get("alertId"))
if raw == "" {
raw = strings.TrimSpace(r.URL.Query().Get("id"))
}
if raw == "" && payload != nil {
// Lenient read: only these two keys matter, everything else in the
// payload is ignored rather than treated as a validation failure.
raw = strings.TrimSpace(lenientString(payload, "alertId"))
if raw == "" {
raw = strings.TrimSpace(lenientString(payload, "id"))
}
}
if raw == "" {
return uuid.Nil, errors.New("alertId is required")
}
id, err := uuid.Parse(raw)
if err != nil {
return uuid.Nil, errors.New("alertId is not a valid uuid")
}
return id, nil
}
// ---- input validation -------------------------------------------------------
func validateName(raw string) (string, error) {
name := strings.TrimSpace(raw)
if name == "" {
return "", errors.New("name is required")
}
if len([]rune(name)) > maxNameChars {
return "", fmt.Errorf("name must be at most %d characters", maxNameChars)
}
return name, nil
}
func validateMessage(raw string) (string, error) {
if len(raw) > maxMessageBytes {
return "", fmt.Errorf("message must be at most %d bytes", maxMessageBytes)
}
return raw, nil
}
func validateAutoStop(seconds int) error {
if seconds < 0 || seconds > alertstate.MaxAutoStopSeconds {
return fmt.Errorf("autoStopSeconds must be between 0 and %d", alertstate.MaxAutoStopSeconds)
}
return nil
}
func validateDeviceID(raw string) (string, error) {
id := strings.TrimSpace(raw)
if id == "" {
return "", errors.New("deviceId is required")
}
if len(id) > maxDeviceIDLen {
return "", fmt.Errorf("deviceId must be at most %d characters", maxDeviceIDLen)
}
for i := 0; i < len(id); i++ {
if id[i] < 0x20 || id[i] == 0x7f {
return "", errors.New("deviceId must not contain control characters")
}
}
return id, nil
}
func validateOptional(raw string, field string, max int) (*string, error) {
s := strings.TrimSpace(raw)
if s == "" {
return nil, nil
}
if len(s) > max {
return nil, fmt.Errorf("%s must be at most %d characters", field, max)
}
for i := 0; i < len(s); i++ {
if s[i] < 0x20 || s[i] == 0x7f {
return nil, fmt.Errorf("%s must not contain control characters", field)
}
}
return &s, nil
}
// ---- shared views -----------------------------------------------------------
// rfc3339 renders a timestamp for the wire: RFC3339 in UTC.
func rfc3339(t time.Time) string { return t.UTC().Format(time.RFC3339Nano) }
// rfc3339Ptr renders an optional timestamp, omitting it when unset.
func rfc3339Ptr(t *time.Time) *string {
if t == nil {
return nil
}
s := rfc3339(*t)
return &s
}
+198
View File
@@ -0,0 +1,198 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package actions
import (
"net/http"
"strings"
"time"
"git.dcntrlized.org/Dcntrlized/Alarm/db"
"git.dcntrlized.org/Dcntrlized/Alarm/internal/alertstate"
"git.dcntrlized.org/Dcntrlized/Alarm/realtime"
)
// actionResponse is the trigger/disarm result.
type actionResponse struct {
EventID string `json:"eventId"`
Action string `json:"action"`
ServerTime string `json:"serverTime"`
AutoStopSeconds int `json:"autoStopSeconds"`
Delivered int `json:"delivered"`
}
type triggerRequest struct {
AlertID string `json:"alertId"`
AdminToken string `json:"adminToken"`
Message *string `json:"message"`
}
// TriggerAlert implements POST /action/triggerAlert.
//
// The state transition and the event row are written in one transaction, and
// the schema's NOTIFY trigger publishes the event on commit, so every service
// instance fans it out. This instance additionally broadcasts in-process for
// the fast path; the hub de-duplicates the two by event id.
func TriggerAlert(w http.ResponseWriter, r *http.Request, payload any) {
const action = "triggerAlert"
alert, p, ok := requireAdminToken(w, r, action, payload)
if !ok {
return
}
var req triggerRequest
_ = decodePayload(payload, &req)
message := alert.Message
if req.Message != nil {
m, err := validateMessage(*req.Message)
if err != nil {
validationError(w, "%v", err)
return
}
if strings.TrimSpace(m) != "" {
message = &m
}
}
updated, event, err := db.TriggerAlert(r.Context(), alert.ID, p.ID, message)
if err != nil {
dbError(w, action, err)
return
}
delivered := 0
if hub := realtime.Current(); hub != nil {
delivered = hub.Broadcast(updated.ID, realtime.AlarmFrame{
Type: "alarm",
AlertID: updated.ID.String(),
EventID: event.ID.String(),
Action: alertstate.KindTrigger,
Message: deref(event.Message),
ServerTime: rfc3339(event.CreatedAt),
AutoStopSeconds: updated.AutoStopSeconds,
})
}
Debug(action, "POST", "alert=%s event=%s delivered=%d", updated.ID, event.ID, delivered)
writeJSON(w, http.StatusOK, actionResponse{
EventID: event.ID.String(),
Action: alertstate.KindTrigger,
ServerTime: rfc3339(event.CreatedAt),
AutoStopSeconds: updated.AutoStopSeconds,
Delivered: delivered,
})
}
// DisarmAlert implements POST /action/disarmAlert.
func DisarmAlert(w http.ResponseWriter, r *http.Request, payload any) {
const action = "disarmAlert"
alert, p, ok := requireAdminToken(w, r, action, payload)
if !ok {
return
}
updated, event, err := db.DisarmAlert(r.Context(), alert.ID, p.ID, nil)
if err != nil {
dbError(w, action, err)
return
}
delivered := 0
if hub := realtime.Current(); hub != nil {
delivered = hub.Broadcast(updated.ID, realtime.AlarmFrame{
Type: "alarm",
AlertID: updated.ID.String(),
EventID: event.ID.String(),
Action: alertstate.KindDisarm,
Message: deref(event.Message),
ServerTime: rfc3339(event.CreatedAt),
AutoStopSeconds: updated.AutoStopSeconds,
})
}
Debug(action, "POST", "alert=%s event=%s delivered=%d", updated.ID, event.ID, delivered)
writeJSON(w, http.StatusOK, actionResponse{
EventID: event.ID.String(),
Action: alertstate.KindDisarm,
ServerTime: rfc3339(event.CreatedAt),
AutoStopSeconds: updated.AutoStopSeconds,
Delivered: delivered,
})
}
// statusView is the alarmStatus polling fallback payload (frozen field set).
type statusView struct {
Active bool `json:"active"`
EventID string `json:"eventId"`
Action string `json:"action"`
Message string `json:"message"`
ServerTime string `json:"serverTime"`
AutoStopSeconds int `json:"autoStopSeconds"`
}
// AlarmStatus implements GET/POST /action/alarmStatus: the cheap, indexed
// fallback a client polls when its socket is down or it was killed mid-alarm.
//
// `active` is derived (status + auto-stop window), never stored, so no
// background job is needed to expire an alarm.
func AlarmStatus(w http.ResponseWriter, r *http.Request, payload any) {
const action = "alarmStatus"
p, ok := principal(w, r, action)
if !ok {
return
}
alertID, err := alertIDFrom(r, payload)
if err != nil {
validationError(w, "%v", err)
return
}
res, err := db.GetAlertWithLatestEvent(r.Context(), alertID)
if err != nil {
dbError(w, action, err)
return
}
allowed, err := isAdminOrSubscriber(r, res.Alert, p.ID)
if err != nil {
dbError(w, action, err)
return
}
if !allowed {
writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "not subscribed to this alert")
return
}
now := time.Now().UTC()
active := alertstate.IsActive(res.Alert.Status, res.Alert.TriggeredAt, res.Alert.AutoStopSeconds, now)
if res.Event == nil || res.Event.Kind != alertstate.KindTrigger {
// A never-triggered alert, or one whose newest event is a disarm, is
// not live even if the stored status says "triggered".
active = false
}
view := statusView{
Active: active,
ServerTime: rfc3339(now),
AutoStopSeconds: res.Alert.AutoStopSeconds,
}
if res.Event != nil {
view.EventID = res.Event.ID.String()
view.Action = res.Event.Kind
view.Message = deref(res.Event.Message)
view.ServerTime = rfc3339(res.Event.CreatedAt)
}
writeJSON(w, http.StatusOK, view)
}
+52
View File
@@ -0,0 +1,52 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package actions
import (
"fmt"
"git.dcntrlized.org/Dcntrlized/Alarm/logger"
)
func init() {
for _, name := range []string{
"git.dcntrlized.org/Dcntrlized/Alarm/actions.Debug",
"git.dcntrlized.org/Dcntrlized/Alarm/actions.Warn",
"git.dcntrlized.org/Dcntrlized/Alarm/actions.Error",
"git.dcntrlized.org/Dcntrlized/Alarm/actions.logAction",
} {
logger.RegisterLogHelper(name)
}
}
// Debug logs at debug level. Callers must never pass tokens or credentials.
func Debug(action, verb, format string, v ...any) {
logAction(logger.Debug, action, verb, format, v...)
}
// Warn logs at warn level. Callers must never pass tokens or credentials.
func Warn(action, verb, format string, v ...any) {
logAction(logger.Warn, action, verb, format, v...)
}
// Error logs at error level. Callers must never pass tokens or credentials.
func Error(action, verb, format string, v ...any) {
logAction(logger.Error, action, verb, format, v...)
}
func logAction(logf func(format string, v ...any), action, verb, format string, v ...any) {
logf("[action=%s verb=%s] %s", action, verb, fmt.Sprintf(format, v...))
}
+130
View File
@@ -0,0 +1,130 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package actions
import (
"errors"
"net/http"
"strconv"
"git.dcntrlized.org/Dcntrlized/Alarm/db"
"git.dcntrlized.org/Dcntrlized/Alarm/internal/qr"
"git.dcntrlized.org/Dcntrlized/Alarm/internal/qrpayload"
)
// QR rendering defaults: 8 px per module and the 4-module quiet zone the QR
// specification requires for reliable scanning.
const (
defaultQRScale = 8
defaultQRQuietZone = 4
)
// QRImage implements GET/POST /action/qrImage: the PNG of the alert's QR code.
//
// It is a convenience — the Android client renders the same payload locally —
// so it is deliberately simple: admin or subscriber, no caching, no ETag. The
// QR encodes the subscribe token, which both audiences already hold (the admin
// through createAlert/qrPayload, the subscriber through the token they scanned).
func QRImage(w http.ResponseWriter, r *http.Request, payload any) {
const action = "qrImage"
p, ok := principal(w, r, action)
if !ok {
return
}
alertID, err := alertIDFrom(r, payload)
if err != nil {
validationError(w, "%v", err)
return
}
alert, err := db.GetAlert(r.Context(), alertID)
if err != nil {
dbError(w, action, err)
return
}
allowed, err := isAdminOrSubscriber(r, alert, p.ID)
if err != nil {
dbError(w, action, err)
return
}
if !allowed {
writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "not subscribed to this alert")
return
}
scale, err := intQuery(r, "scale", defaultQRScale, 1, qr.MaxScale)
if err != nil {
validationError(w, "%v", err)
return
}
// `margin` is the more common name for the quiet zone; accept either.
quiet := defaultQRQuietZone
quietParam := "quietZone"
if r.URL.Query().Get(quietParam) == "" {
quietParam = "margin"
}
if r.URL.Query().Get(quietParam) != "" {
// The lower bound is the ISO minimum, not 0: a symbol with a smaller
// quiet zone is not reliably scannable, so reject it as a validation
// error here instead of serving an image no scanner can read.
quiet, err = intQuery(r, quietParam, defaultQRQuietZone, qr.MinQuietZone, qr.MaxQuietZone)
if err != nil {
validationError(w, "%v", err)
return
}
}
matrix, err := qr.EncodeString(qrpayload.Build(alert.ID, alert.SubscribeToken))
if err != nil {
Error(action, "encode", "%v", err)
writeError(w, http.StatusInternalServerError, CodeInternal, "could not encode the QR code")
return
}
pngBytes, err := qr.PNG(matrix, scale, quiet)
if err != nil {
if errors.Is(err, qr.ErrBadMatrix) {
Error(action, "render", "%v", err)
writeError(w, http.StatusInternalServerError, CodeInternal, "could not render the QR code")
return
}
validationError(w, "%v", err)
return
}
Debug(action, "GET", "alert=%s modules=%d scale=%d quiet=%d", alert.ID, len(matrix), scale, quiet)
w.Header().Set("Content-Type", "image/png")
w.Header().Set("Content-Length", strconv.Itoa(len(pngBytes)))
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(pngBytes)
}
// intQuery reads a bounded integer query parameter.
func intQuery(r *http.Request, name string, def, min, max int) (int, error) {
raw := r.URL.Query().Get(name)
if raw == "" {
return def, nil
}
v, err := strconv.Atoi(raw)
if err != nil {
return 0, errors.New(name + " must be an integer")
}
if v < min || v > max {
return 0, errors.New(name + " is out of range")
}
return v, nil
}
+254
View File
@@ -0,0 +1,254 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package actions
import (
"errors"
"net/http"
"strings"
"git.dcntrlized.org/Dcntrlized/Alarm/db"
"git.dcntrlized.org/Dcntrlized/Alarm/internal/alarmtoken"
"github.com/google/uuid"
)
type subscriptionView struct {
ID string `json:"id"`
UserID string `json:"userId"`
Username string `json:"username,omitempty"`
DeviceID string `json:"deviceId"`
DeviceLabel string `json:"deviceLabel,omitempty"`
Platform string `json:"platform,omitempty"`
Enabled bool `json:"enabled"`
LastSeenAt *string `json:"lastSeenAt,omitempty"`
Created string `json:"created"`
}
// newSubscriptionView maps a row to the wire view.
//
// `username` is deliberately never populated: it lives in the auth service's
// schema, and the Alarm service does not read another service's tables. The
// field is omitted rather than sent empty.
func newSubscriptionView(s *db.Subscription) subscriptionView {
return subscriptionView{
ID: s.ID.String(),
UserID: s.SubscriberUserID.String(),
DeviceID: s.DeviceID,
DeviceLabel: deref(s.DeviceLabel),
Platform: deref(s.Platform),
Enabled: s.Enabled,
LastSeenAt: rfc3339Ptr(s.LastSeenAt),
Created: rfc3339(s.Created),
}
}
// ---- joinAlert --------------------------------------------------------------
type joinRequest struct {
AlertID string `json:"alertId"`
SubscribeToken string `json:"subscribeToken"`
DeviceID string `json:"deviceId"`
DeviceLabel string `json:"deviceLabel"`
Platform string `json:"platform"`
}
// JoinAlert implements POST /action/joinAlert.
//
// Idempotency (frozen contract): a repeat for the same
// (alertId, user, deviceId) returns the existing subscription with 200 and
// creates nothing. A different deviceId creates a second subscription for the
// same user. ERR_ALERT_ALREADY_JOINED is reserved for the genuine concurrent
// race, where two inserts for the same device hit the unique index at once.
func JoinAlert(w http.ResponseWriter, r *http.Request, payload any) {
const action = "joinAlert"
p, ok := principal(w, r, action)
if !ok {
return
}
var req joinRequest
if err := decodePayload(payload, &req); err != nil {
validationError(w, "invalid payload: %v", err)
return
}
alertID, err := uuid.Parse(strings.TrimSpace(req.AlertID))
if err != nil {
validationError(w, "alertId is not a valid uuid")
return
}
deviceID, err := validateDeviceID(req.DeviceID)
if err != nil {
validationError(w, "%v", err)
return
}
label, err := validateOptional(req.DeviceLabel, "deviceLabel", maxLabelLen)
if err != nil {
validationError(w, "%v", err)
return
}
platform, err := validateOptional(req.Platform, "platform", maxPlatformLen)
if err != nil {
validationError(w, "%v", err)
return
}
if !alarmtoken.ValidSubscribeTokenShape(strings.TrimSpace(req.SubscribeToken)) {
writeError(w, http.StatusForbidden, CodeAlertTokenInvalid, "invalid subscribe token")
return
}
alert, err := db.GetAlert(r.Context(), alertID)
if err != nil {
dbError(w, action, err)
return
}
if !alarmtoken.EqualToken(alert.SubscribeToken, strings.TrimSpace(req.SubscribeToken)) {
Warn(action, "auth", "subscribe token mismatch alert=%s user=%s", alert.ID, p.ID)
writeError(w, http.StatusForbidden, CodeAlertTokenInvalid, "invalid subscribe token")
return
}
existing, err := db.GetSubscriptionByDevice(r.Context(), alertID, p.ID, deviceID)
switch {
case err == nil:
Debug(action, "POST", "idempotent re-join alert=%s sub=%s device=%s", alertID, existing.ID, deviceID)
writeJSON(w, http.StatusOK, newSubscriptionView(existing))
return
case !errors.Is(err, db.ErrNotFound):
dbError(w, action, err)
return
}
sub, err := db.CreateSubscription(r.Context(), &db.Subscription{
AlertID: alertID,
SubscriberUserID: p.ID,
DeviceID: deviceID,
DeviceLabel: label,
Platform: platform,
})
if errors.Is(err, db.ErrConflict) {
// Two concurrent joins for the same device: the sequential repeat path
// above already handled the normal re-scan case.
Warn(action, "POST", "concurrent duplicate join alert=%s user=%s device=%s", alertID, p.ID, deviceID)
writeError(w, http.StatusConflict, CodeAlertAlreadyJoined, "device is already subscribed to this alert")
return
}
if err != nil {
dbError(w, action, err)
return
}
Debug(action, "POST", "joined alert=%s user=%s sub=%s device=%s", alertID, p.ID, sub.ID, deviceID)
writeJSON(w, http.StatusOK, newSubscriptionView(sub))
}
// ---- listSubscriptions ------------------------------------------------------
type listSubscriptionsResponse struct {
Items []subscriptionView `json:"items"`
}
// ListSubscriptions implements GET/POST /action/listSubscriptions. Admin only.
func ListSubscriptions(w http.ResponseWriter, r *http.Request, payload any) {
const action = "listSubscriptions"
alert, _, ok := requireOwner(w, r, action, payload)
if !ok {
return
}
subs, err := db.ListSubscriptions(r.Context(), alert.ID)
if err != nil {
dbError(w, action, err)
return
}
items := make([]subscriptionView, 0, len(subs))
for i := range subs {
items = append(items, newSubscriptionView(&subs[i]))
}
writeJSON(w, http.StatusOK, listSubscriptionsResponse{Items: items})
}
// ---- removeSubscription -----------------------------------------------------
type removeSubscriptionRequest struct {
AlertID string `json:"alertId"`
SubscriptionID string `json:"subscriptionId"`
}
// RemoveSubscription implements POST/DELETE /action/removeSubscription. The
// alert's admin may remove any device; a subscriber may remove their own.
func RemoveSubscription(w http.ResponseWriter, r *http.Request, payload any) {
const action = "removeSubscription"
p, ok := principal(w, r, action)
if !ok {
return
}
var req removeSubscriptionRequest
_ = decodePayload(payload, &req)
if req.SubscriptionID == "" {
req.SubscriptionID = strings.TrimSpace(r.URL.Query().Get("subscriptionId"))
}
if req.AlertID == "" {
req.AlertID = strings.TrimSpace(r.URL.Query().Get("alertId"))
}
alertID, err := uuid.Parse(strings.TrimSpace(req.AlertID))
if err != nil {
validationError(w, "alertId is not a valid uuid")
return
}
subscriptionID, err := uuid.Parse(strings.TrimSpace(req.SubscriptionID))
if err != nil {
validationError(w, "subscriptionId is not a valid uuid")
return
}
sub, err := db.GetSubscription(r.Context(), alertID, subscriptionID)
if err != nil {
if errors.Is(err, db.ErrNotFound) {
writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "subscription not found")
return
}
dbError(w, action, err)
return
}
if sub.SubscriberUserID != p.ID {
// Not the owner: only the alert admin may remove someone else's device.
// A stranger gets the same code as a missing row so the endpoint never
// confirms that the subscription exists.
alert, err := db.GetAlert(r.Context(), alertID)
if err != nil {
dbError(w, action, err)
return
}
if alert.AdminUserID != p.ID {
Warn(action, "auth", "user=%s may not remove sub=%s", p.ID, subscriptionID)
writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "subscription not found")
return
}
}
if err := db.DeleteSubscription(r.Context(), alertID, subscriptionID); err != nil {
dbError(w, action, err)
return
}
Debug(action, "POST", "removed sub=%s alert=%s by=%s", subscriptionID, alertID, p.ID)
writeJSON(w, http.StatusOK, map[string]any{
"alertId": alertID.String(),
"subscriptionId": subscriptionID.String(),
"removed": true,
})
}
+176
View File
@@ -0,0 +1,176 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package db
import (
"context"
"errors"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// Alert is one alarm.alerts row.
type Alert struct {
ID uuid.UUID
AdminUserID uuid.UUID
Name string
Message *string
Status string
AdminTokenHash []byte
SubscribeToken string
TokenVersion int
AutoStopSeconds int
Created time.Time
Modified time.Time
TriggeredAt *time.Time
DisarmedAt *time.Time
}
const alertColumns = `id, admin_user_id, name, message, status::text, admin_token_hash,
subscribe_token, token_version, auto_stop_seconds, created, modified, triggered_at, disarmed_at`
// alertColumnsAliased is the same list qualified with the `a` alias used by the
// join in GetAlertWithLatestEvent.
const alertColumnsAliased = `a.id, a.admin_user_id, a.name, a.message, a.status::text, a.admin_token_hash,
a.subscribe_token, a.token_version, a.auto_stop_seconds, a.created, a.modified, a.triggered_at, a.disarmed_at`
func scanAlert(row pgx.Row) (*Alert, error) {
var a Alert
err := row.Scan(&a.ID, &a.AdminUserID, &a.Name, &a.Message, &a.Status, &a.AdminTokenHash,
&a.SubscribeToken, &a.TokenVersion, &a.AutoStopSeconds, &a.Created, &a.Modified,
&a.TriggeredAt, &a.DisarmedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
return &a, nil
}
// CreateAlert inserts a new alert and returns the stored row.
func CreateAlert(ctx context.Context, a *Alert) (*Alert, error) {
const q = `INSERT INTO alarm.alerts
(admin_user_id, name, message, admin_token_hash, subscribe_token, auto_stop_seconds)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING ` + alertColumns
return scanAlert(Pool().QueryRow(ctx, q,
a.AdminUserID, a.Name, a.Message, a.AdminTokenHash, a.SubscribeToken, a.AutoStopSeconds))
}
// GetAlert fetches one alert by id. It returns ErrNotFound when the row is gone.
func GetAlert(ctx context.Context, alertID uuid.UUID) (*Alert, error) {
const q = `SELECT ` + alertColumns + ` FROM alarm.alerts WHERE id = $1`
return scanAlert(Pool().QueryRow(ctx, q, alertID))
}
// DeleteAlert removes an alert; subscriptions and events cascade. It reports
// ErrNotFound when nothing was deleted, so a repeated delete is visible.
func DeleteAlert(ctx context.Context, alertID uuid.UUID) error {
tag, err := Pool().Exec(ctx, `DELETE FROM alarm.alerts WHERE id = $1`, alertID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// RotateSubscribeToken replaces the subscribe token and bumps token_version.
// Existing subscriptions are untouched: rotation invalidates the QR/join path,
// not the subscribers who already joined.
func RotateSubscribeToken(ctx context.Context, alertID uuid.UUID, newToken string) (int, error) {
var version int
err := Pool().QueryRow(ctx,
`UPDATE alarm.alerts SET subscribe_token = $2, token_version = token_version + 1
WHERE id = $1 RETURNING token_version`, alertID, newToken).Scan(&version)
if errors.Is(err, pgx.ErrNoRows) {
return 0, ErrNotFound
}
if err != nil {
return 0, err
}
return version, nil
}
// CountSubscriptions returns how many devices are subscribed to an alert.
func CountSubscriptions(ctx context.Context, alertID uuid.UUID) (int, error) {
var n int
err := Pool().QueryRow(ctx,
`SELECT COUNT(*) FROM alarm.subscriptions WHERE alert_id = $1`, alertID).Scan(&n)
return n, err
}
// AlertWithLatestEvent is the cheap single-round-trip read behind alarmStatus
// and getAlert: the alert row plus its newest event (nil when it has none).
type AlertWithLatestEvent struct {
Alert *Alert
Event *Event
}
// GetAlertWithLatestEvent runs one indexed query: the alert plus a LATERAL
// lookup of its newest event on (alert_id, created_at DESC, id DESC).
func GetAlertWithLatestEvent(ctx context.Context, alertID uuid.UUID) (*AlertWithLatestEvent, error) {
const q = `
SELECT ` + alertColumnsAliased + `,
e.id, e.alert_id, e.kind::text, e.actor_user_id, e.message, e.created_at
FROM alarm.alerts a
LEFT JOIN LATERAL (
SELECT id, alert_id, kind, actor_user_id, message, created_at
FROM alarm.alarm_events
WHERE alert_id = a.id
ORDER BY created_at DESC, id DESC
LIMIT 1
) e ON TRUE
WHERE a.id = $1`
var a Alert
var (
evID *uuid.UUID
evAlertID *uuid.UUID
evKind *string
evActor *uuid.UUID
evMessage *string
evCreated *time.Time
)
err := Pool().QueryRow(ctx, q, alertID).Scan(
&a.ID, &a.AdminUserID, &a.Name, &a.Message, &a.Status, &a.AdminTokenHash,
&a.SubscribeToken, &a.TokenVersion, &a.AutoStopSeconds, &a.Created, &a.Modified,
&a.TriggeredAt, &a.DisarmedAt,
&evID, &evAlertID, &evKind, &evActor, &evMessage, &evCreated,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
out := &AlertWithLatestEvent{Alert: &a}
if evID != nil {
out.Event = &Event{
ID: *evID,
AlertID: *evAlertID,
Kind: *evKind,
ActorUserID: *evActor,
Message: evMessage,
CreatedAt: *evCreated,
}
}
return out, nil
}
+146
View File
@@ -0,0 +1,146 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// Package db owns the Alarm service's PostgreSQL access: the pool bootstrap and
// one file per table family (alerts, subscriptions, events). All state
// transitions that must be atomic (trigger/disarm) run inside a transaction
// with the alert row locked, so two concurrent admins cannot interleave.
package db
import (
"context"
"errors"
"log"
"os"
"strings"
"sync"
"time"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
)
// Sentinel errors mapped to the wire error codes by the action layer.
var (
// ErrNotFound is returned when a row does not exist.
ErrNotFound = errors.New("db: not found")
// ErrConflict is returned for a unique-constraint violation (23505).
ErrConflict = errors.New("db: conflict")
// ErrNotAdmin is returned when the caller is not the alert's admin.
ErrNotAdmin = errors.New("db: caller is not the alert admin")
// ErrState is returned when a transition is illegal for the current status.
ErrState = errors.New("db: illegal state transition")
)
// DB is the process-wide connection pool holder.
type DB struct {
Pool *pgxpool.Pool
}
var (
instance *DB
once sync.Once
)
// DatabaseURL returns DATABASE_URL, defaulting to the shared database as
// reachable by DNS on the dcntrlized network. Host-side development overrides
// it (e.g. postgres://postgres:dcntrlized@localhost:55432/dcntrlized). The
// shared database authenticates with scram-sha-256, hence the shared password
// in the default; Maestro injects its own DSN for registry-managed services.
func DatabaseURL() string {
if url := os.Getenv("DATABASE_URL"); url != "" {
return url
}
return "postgres://postgres:dcntrlized@dcntrlized-db:5432/dcntrlized?sslmode=disable"
}
// Pool returns the process-wide pool, creating it on first use. Creation
// tolerates startup ordering: the shared database container (or the migrate
// one-shot applying the schema) may not be ready when this service boots, so
// the pool pings until it is reachable.
func Pool() *pgxpool.Pool {
once.Do(func() {
instance = newDB()
})
return instance.Pool
}
// NewDB creates (or returns) the pool and is safe to call more than once.
func NewDB() *DB {
Pool()
return instance
}
func newDB() *DB {
url := DatabaseURL()
pool, err := pgxpool.New(context.Background(), url)
if err != nil {
log.Fatalf("Unable to create database pool: %v", err)
}
pingCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
for {
if err := pool.Ping(pingCtx); err == nil {
break
} else {
select {
case <-pingCtx.Done():
pool.Close()
log.Fatalf("Database %s unreachable within 30s: %v", redact(url), err)
case <-time.After(500 * time.Millisecond):
}
}
}
return &DB{Pool: pool}
}
// Close releases the pool.
func Close() {
if instance != nil && instance.Pool != nil {
instance.Pool.Close()
}
}
// redact strips the password from a DSN before it can reach a log line:
// postgres://user:password@host -> postgres://user:***@host.
func redact(dsn string) string {
scheme := strings.Index(dsn, "://")
if scheme < 0 {
return dsn
}
at := strings.LastIndexByte(dsn, '@')
if at < 0 {
return dsn
}
colon := strings.IndexByte(dsn[scheme+3:], ':')
if colon < 0 {
return dsn
}
colon += scheme + 3
if colon >= at {
return dsn
}
return dsn[:colon+1] + "***" + dsn[at:]
}
// isUniqueViolation reports whether err is a PostgreSQL 23505.
func isUniqueViolation(err error) bool {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
return pgErr.Code == "23505"
}
return false
}
+144
View File
@@ -0,0 +1,144 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package db
import (
"context"
"errors"
"time"
"git.dcntrlized.org/Dcntrlized/Alarm/internal/alertstate"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// Event is one alarm.alarm_events row (append-only trigger/disarm log).
type Event struct {
ID uuid.UUID
AlertID uuid.UUID
Kind string
ActorUserID uuid.UUID
Message *string
CreatedAt time.Time
}
const eventColumns = `id, alert_id, kind::text, actor_user_id, message, created_at`
func scanEvent(row pgx.Row) (*Event, error) {
var e Event
err := row.Scan(&e.ID, &e.AlertID, &e.Kind, &e.ActorUserID, &e.Message, &e.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
return &e, nil
}
// LatestEvent returns the newest event for an alert (ErrNotFound when the alert
// has never fired). It is the indexed read behind alarmStatus.
func LatestEvent(ctx context.Context, alertID uuid.UUID) (*Event, error) {
const q = `SELECT ` + eventColumns + `
FROM alarm.alarm_events WHERE alert_id = $1
ORDER BY created_at DESC, id DESC LIMIT 1`
return scanEvent(Pool().QueryRow(ctx, q, alertID))
}
// TriggerAlert transitions an alert to triggered and appends the event, in one
// transaction with the alert row locked. Re-triggering a live alert is allowed
// and restarts the auto-stop window; it produces a new event id so every client
// re-fires and the hub's event-id de-duplication does not swallow it.
//
// The caller must already have verified the admin token; this function
// re-checks the actor against admin_user_id so the check cannot be skipped.
func TriggerAlert(ctx context.Context, alertID, actorUserID uuid.UUID, message *string) (*Alert, *Event, error) {
return transition(ctx, alertID, actorUserID, alertstate.KindTrigger, message, nil)
}
// DisarmAlert transitions an alert to disarmed and appends the event. Disarming
// an alert that is not triggered is ErrState (the client's view is stale).
func DisarmAlert(ctx context.Context, alertID, actorUserID uuid.UUID, message *string) (*Alert, *Event, error) {
guard := func(a *Alert) error {
if !alertstate.CanDisarm(a.Status) {
return ErrState
}
return nil
}
return transition(ctx, alertID, actorUserID, alertstate.KindDisarm, message, guard)
}
func transition(
ctx context.Context,
alertID, actorUserID uuid.UUID,
kind string,
message *string,
guard func(*Alert) error,
) (*Alert, *Event, error) {
tx, err := Pool().Begin(ctx)
if err != nil {
return nil, nil, err
}
defer func() { _ = tx.Rollback(ctx) }()
// Lock the row for the whole transition so two concurrent commands cannot
// interleave their status writes.
current, err := scanAlert(tx.QueryRow(ctx,
`SELECT `+alertColumns+` FROM alarm.alerts WHERE id = $1 FOR UPDATE`, alertID))
if err != nil {
return nil, nil, err
}
if current.AdminUserID != actorUserID {
return nil, nil, ErrNotAdmin
}
if guard != nil {
if err := guard(current); err != nil {
return nil, nil, err
}
}
var updated *Alert
switch kind {
case alertstate.KindTrigger:
updated, err = scanAlert(tx.QueryRow(ctx,
`UPDATE alarm.alerts SET status = 'triggered', triggered_at = NOW()
WHERE id = $1 RETURNING `+alertColumns, alertID))
case alertstate.KindDisarm:
updated, err = scanAlert(tx.QueryRow(ctx,
`UPDATE alarm.alerts SET status = 'disarmed', disarmed_at = NOW()
WHERE id = $1 RETURNING `+alertColumns, alertID))
default:
return nil, nil, errors.New("db: unknown event kind")
}
if err != nil {
return nil, nil, err
}
event, err := scanEvent(tx.QueryRow(ctx,
`INSERT INTO alarm.alarm_events (alert_id, kind, actor_user_id, message)
VALUES ($1, $2, $3, $4) RETURNING `+eventColumns,
alertID, kind, actorUserID, message))
if err != nil {
return nil, nil, err
}
// The NOTIFY trigger fires on COMMIT, so no frame can be observed before
// the new status is visible to every reader.
if err := tx.Commit(ctx); err != nil {
return nil, nil, err
}
return updated, event, nil
}
+164
View File
@@ -0,0 +1,164 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package db
import (
"context"
"errors"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// Subscription is one alarm.subscriptions row: a device that receives the alarm.
type Subscription struct {
ID uuid.UUID
AlertID uuid.UUID
SubscriberUserID uuid.UUID
DeviceID string
DeviceLabel *string
Platform *string
Enabled bool
LastSeenAt *time.Time
Created time.Time
}
const subscriptionColumns = `id, alert_id, subscriber_user_id, device_id, device_label,
platform, enabled, last_seen_at, created`
func scanSubscription(row pgx.Row) (*Subscription, error) {
var s Subscription
err := row.Scan(&s.ID, &s.AlertID, &s.SubscriberUserID, &s.DeviceID, &s.DeviceLabel,
&s.Platform, &s.Enabled, &s.LastSeenAt, &s.Created)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
return &s, nil
}
// CreateSubscription inserts a subscription. A duplicate
// (alert_id, subscriber_user_id, device_id) is reported as ErrConflict, which
// the join action turns into the ERR_ALERT_ALREADY_JOINED race fallback — the
// sequential re-scan path is handled by the caller's lookup and never reaches
// this error.
func CreateSubscription(ctx context.Context, s *Subscription) (*Subscription, error) {
const q = `INSERT INTO alarm.subscriptions
(alert_id, subscriber_user_id, device_id, device_label, platform)
VALUES ($1, $2, $3, $4, $5)
RETURNING ` + subscriptionColumns
row, err := scanSubscription(Pool().QueryRow(ctx, q,
s.AlertID, s.SubscriberUserID, s.DeviceID, s.DeviceLabel, s.Platform))
if isUniqueViolation(err) {
return nil, ErrConflict
}
return row, err
}
// GetSubscriptionByDevice is the idempotency lookup for joinAlert.
func GetSubscriptionByDevice(ctx context.Context, alertID, userID uuid.UUID, deviceID string) (*Subscription, error) {
const q = `SELECT ` + subscriptionColumns + `
FROM alarm.subscriptions
WHERE alert_id = $1 AND subscriber_user_id = $2 AND device_id = $3`
return scanSubscription(Pool().QueryRow(ctx, q, alertID, userID, deviceID))
}
// GetSubscription fetches one subscription scoped to its alert.
func GetSubscription(ctx context.Context, alertID, subscriptionID uuid.UUID) (*Subscription, error) {
const q = `SELECT ` + subscriptionColumns + `
FROM alarm.subscriptions WHERE alert_id = $1 AND id = $2`
return scanSubscription(Pool().QueryRow(ctx, q, alertID, subscriptionID))
}
// ListSubscriptions returns every device subscribed to an alert, oldest first.
func ListSubscriptions(ctx context.Context, alertID uuid.UUID) ([]Subscription, error) {
const q = `SELECT ` + subscriptionColumns + `
FROM alarm.subscriptions WHERE alert_id = $1 ORDER BY created, id`
rows, err := Pool().Query(ctx, q, alertID)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]Subscription, 0, 4)
for rows.Next() {
var s Subscription
if err := rows.Scan(&s.ID, &s.AlertID, &s.SubscriberUserID, &s.DeviceID, &s.DeviceLabel,
&s.Platform, &s.Enabled, &s.LastSeenAt, &s.Created); err != nil {
return nil, err
}
out = append(out, s)
}
return out, rows.Err()
}
// DeleteSubscription removes one subscription. ErrNotFound when it was already
// gone, so a repeated removal is visible to the caller.
func DeleteSubscription(ctx context.Context, alertID, subscriptionID uuid.UUID) error {
tag, err := Pool().Exec(ctx,
`DELETE FROM alarm.subscriptions WHERE alert_id = $1 AND id = $2`, alertID, subscriptionID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// IsSubscriber reports whether the user has at least one subscription row on
// the alert. It is the authorisation check behind getAlert/alarmStatus.
func IsSubscriber(ctx context.Context, alertID, userID uuid.UUID) (bool, error) {
var exists bool
err := Pool().QueryRow(ctx,
`SELECT EXISTS (SELECT 1 FROM alarm.subscriptions WHERE alert_id = $1 AND subscriber_user_id = $2)`,
alertID, userID).Scan(&exists)
return exists, err
}
// UserSubscriptionIDs lists the user's subscription ids on an alert, so a WS
// connection can be tied to the right device rows.
func UserSubscriptionIDs(ctx context.Context, alertID, userID uuid.UUID) ([]uuid.UUID, error) {
rows, err := Pool().Query(ctx,
`SELECT id FROM alarm.subscriptions WHERE alert_id = $1 AND subscriber_user_id = $2 ORDER BY created`,
alertID, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []uuid.UUID
for rows.Next() {
var id uuid.UUID
if err := rows.Scan(&id); err != nil {
return nil, err
}
out = append(out, id)
}
return out, rows.Err()
}
// TouchSubscription stamps last_seen_at. It is called on WS connect and on
// every heartbeat; a no-op update (the row vanished mid-connection) is not an
// error worth propagating.
func TouchSubscription(ctx context.Context, subscriptionID uuid.UUID) error {
_, err := Pool().Exec(ctx,
`UPDATE alarm.subscriptions SET last_seen_at = NOW() WHERE id = $1`, subscriptionID)
return err
}
+20
View File
@@ -0,0 +1,20 @@
module git.dcntrlized.org/Dcntrlized/Alarm
require (
git.dcntrlized.org/dcntrlized/dcntrlized-auth v0.1.5
github.com/boombuler/barcode v1.0.1
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/jackc/pgx/v5 v5.10.0
)
require (
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/text v0.40.0 // indirect
)
go 1.26.5
+36
View File
@@ -0,0 +1,36 @@
git.dcntrlized.org/dcntrlized/dcntrlized-auth v0.1.5 h1:T2To6w6+8J+PlvresQawoqVUiKZWSLG0FtRLuTS/4L4=
git.dcntrlized.org/dcntrlized/dcntrlized-auth v0.1.5/go.mod h1:BCGcoKSC+yVLRDnYZGUCBcrgu7NaFWdA39PdGG1+oNM=
github.com/boombuler/barcode v1.0.1 h1:NDBbPmhS+EqABEs5Kg3n/5ZNjy73Pz7SIV+KCeqyXcs=
github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+131
View File
@@ -0,0 +1,131 @@
package actions
import (
"encoding/json"
"net/http"
"git.dcntrlized.org/Dcntrlized/Alarm/actions"
"git.dcntrlized.org/Dcntrlized/Alarm/types"
)
// ActionActionNameGet dispatches the GET /action/{actionName} request.
//
// GET carries no body: the read actions take their arguments from the query
// string (e.g. /action/getAlert?alertId=…). The POST switch below accepts the
// same actions with a {payload} envelope for clients that prefer one verb.
func ActionActionNameGet(w http.ResponseWriter, r *http.Request, params map[string]string) {
action := params["actionName"]
switch action {
case "getAlert":
actions.GetAlert(w, r, nil)
case "listSubscriptions":
actions.ListSubscriptions(w, r, nil)
case "alarmStatus":
actions.AlarmStatus(w, r, nil)
case "qrImage":
actions.QRImage(w, r, nil)
case "logout":
actions.Logout(w, r, nil)
case "me":
actions.Me(w, r)
default:
http.Error(w, "Action not implemented", http.StatusNotImplemented)
return
}
}
// ActionActionNamePost dispatches the POST /action/{actionName} request.
func ActionActionNamePost(w http.ResponseWriter, r *http.Request, params map[string]string) {
action := params["actionName"]
var req types.ActionRequest
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
switch action {
// Alarm actions.
case "createAlert":
actions.CreateAlert(w, r, req.Payload)
case "getAlert":
actions.GetAlert(w, r, req.Payload)
case "joinAlert":
actions.JoinAlert(w, r, req.Payload)
case "listSubscriptions":
actions.ListSubscriptions(w, r, req.Payload)
case "removeSubscription":
actions.RemoveSubscription(w, r, req.Payload)
case "triggerAlert":
actions.TriggerAlert(w, r, req.Payload)
case "disarmAlert":
actions.DisarmAlert(w, r, req.Payload)
case "alarmStatus":
actions.AlarmStatus(w, r, req.Payload)
case "deleteAlert":
actions.DeleteAlert(w, r, req.Payload)
case "qrImage":
actions.QRImage(w, r, req.Payload)
case "rotateSubscribeToken":
actions.RotateSubscribeToken(w, r, req.Payload)
// Shared-auth proxies.
case "login":
actions.Login(w, r, req.Payload)
case "register":
actions.Register(w, r, req.Payload)
case "refresh":
actions.Refresh(w, r, req.Payload)
case "delete":
actions.Delete(w, r, req.Payload)
case "logout":
actions.Logout(w, r, req.Payload)
default:
http.Error(w, "Action not implemented", http.StatusNotImplemented)
return
}
}
// ActionActionNamePut dispatches the PUT /action/{actionName} request.
func ActionActionNamePut(w http.ResponseWriter, r *http.Request, params map[string]string) {
action := params["actionName"]
var req types.ActionRequest
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
switch action {
default:
http.Error(w, "Action not implemented", http.StatusNotImplemented)
return
}
}
// ActionActionNameDelete dispatches the DELETE /action/{actionName} request.
// The two removals are also reachable as POST; DELETE is the REST-idiomatic
// verb and carries the same {payload} envelope.
func ActionActionNameDelete(w http.ResponseWriter, r *http.Request, params map[string]string) {
action := params["actionName"]
var req types.ActionRequest
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
switch action {
case "deleteAlert":
actions.DeleteAlert(w, r, req.Payload)
case "removeSubscription":
actions.RemoveSubscription(w, r, req.Payload)
default:
http.Error(w, "Action not implemented", http.StatusNotImplemented)
return
}
}
+26
View File
@@ -0,0 +1,26 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package auth
import (
"git.dcntrlized.org/dcntrlized/dcntrlized-auth/authproxy"
"net/http"
)
// AuthDeletePost proxies POST /auth/delete to the shared dcntrlized-auth service.
func AuthDeletePost(w http.ResponseWriter, r *http.Request, params map[string]string) {
authproxy.Proxy(w, r, "delete")
}
+26
View File
@@ -0,0 +1,26 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package auth
import (
"git.dcntrlized.org/dcntrlized/dcntrlized-auth/authproxy"
"net/http"
)
// AuthLoginPost proxies POST /auth/login to the shared dcntrlized-auth service.
func AuthLoginPost(w http.ResponseWriter, r *http.Request, params map[string]string) {
authproxy.Proxy(w, r, "login")
}
+31
View File
@@ -0,0 +1,31 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package auth
import (
"git.dcntrlized.org/dcntrlized/dcntrlized-auth/authproxy"
"net/http"
)
// AuthLogoutGet proxies GET /auth/logout to the shared dcntrlized-auth service.
func AuthLogoutGet(w http.ResponseWriter, r *http.Request, params map[string]string) {
authproxy.ProxyEmpty(w, r, "logout")
}
// AuthLogoutPost proxies POST /auth/logout to the shared dcntrlized-auth service.
func AuthLogoutPost(w http.ResponseWriter, r *http.Request, params map[string]string) {
authproxy.Proxy(w, r, "logout")
}
+26
View File
@@ -0,0 +1,26 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package auth
import (
"git.dcntrlized.org/dcntrlized/dcntrlized-auth/authproxy"
"net/http"
)
// AuthMeGet proxies GET /auth/me to the shared dcntrlized-auth service.
func AuthMeGet(w http.ResponseWriter, r *http.Request, params map[string]string) {
authproxy.ProxyEmpty(w, r, "me")
}
+26
View File
@@ -0,0 +1,26 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package auth
import (
"git.dcntrlized.org/dcntrlized/dcntrlized-auth/authproxy"
"net/http"
)
// AuthRefreshPost proxies POST /auth/refresh to the shared dcntrlized-auth service.
func AuthRefreshPost(w http.ResponseWriter, r *http.Request, params map[string]string) {
authproxy.Proxy(w, r, "refresh")
}
+26
View File
@@ -0,0 +1,26 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package auth
import (
"git.dcntrlized.org/dcntrlized/dcntrlized-auth/authproxy"
"net/http"
)
// AuthRegisterPost proxies POST /auth/register to the shared dcntrlized-auth service.
func AuthRegisterPost(w http.ResponseWriter, r *http.Request, params map[string]string) {
authproxy.Proxy(w, r, "register")
}
+18
View File
@@ -0,0 +1,18 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package auth
// No inline route-specific types were generated for this tag.
+30
View File
@@ -0,0 +1,30 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package manifest
import (
_ "embed"
"net/http"
)
//go:embed manifest.json
var manifest string
func ManifestHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(manifest))
}
+11
View File
@@ -0,0 +1,11 @@
{
"name": "Alarm",
"author": "Dcntrlized",
"capabilities": [
"database",
"alarm.live"
],
"has_auth": true,
"public_key": "",
"icon": ""
}
+114
View File
@@ -0,0 +1,114 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// Package alarmtoken holds the Alarm service's two bearer secrets and their
// comparison rules (frozen contract v1):
//
// - the ADMIN token is 32 random bytes, base64url (43 chars). It is returned
// exactly once at alert creation and stored ONLY as its SHA-256 digest, so
// a database leak does not yield a usable admin token. Comparison is
// constant-time over the digest.
// - the SUBSCRIBE token is 24 random bytes, base64url (32 chars). It is
// stored in plaintext on purpose: it is shared through the QR code with
// every subscriber, and rotation is a cheap UPDATE (token_version). It is
// still compared in constant time so a timing oracle cannot be used to
// recover it byte by byte.
package alarmtoken
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
)
const (
// AdminTokenBytes is the entropy of an admin token (frozen contract: 32).
AdminTokenBytes = 32
// SubscribeTokenBytes is the entropy of a subscribe token (frozen contract: 24).
SubscribeTokenBytes = 24
// HashBytes is the length of the stored SHA-256 digest.
HashBytes = sha256.Size
)
// ErrRandomSource reports a failure of crypto/rand. It is returned rather than
// panicking so a caller can answer 500 instead of killing the process.
var ErrRandomSource = errors.New("alarmtoken: crypto/rand failed")
// NewAdminToken returns a fresh 32-byte admin token, base64url without padding.
func NewAdminToken() (string, error) {
return randomToken(AdminTokenBytes)
}
// NewSubscribeToken returns a fresh 24-byte subscribe token, base64url without
// padding (32 characters).
func NewSubscribeToken() (string, error) {
return randomToken(SubscribeTokenBytes)
}
func randomToken(n int) (string, error) {
buf := make([]byte, n)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("%w: %v", ErrRandomSource, err)
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}
// Hash returns the SHA-256 digest of a token. That digest is what is persisted
// for admin tokens; the plaintext never touches the database.
func Hash(token string) []byte {
sum := sha256.Sum256([]byte(token))
return sum[:]
}
// EqualHash reports whether token hashes to stored. It is constant-time with
// respect to the digest contents and rejects a stored digest of the wrong
// length up front (a malformed row must never compare equal).
func EqualHash(stored []byte, token string) bool {
if len(stored) != HashBytes {
return false
}
return subtle.ConstantTimeCompare(stored, Hash(token)) == 1
}
// EqualToken compares two plaintext tokens in constant time. Both sides are
// hashed first, so the comparison time does not depend on either length and a
// length mismatch cannot short-circuit.
func EqualToken(a, b string) bool {
ha := sha256.Sum256([]byte(a))
hb := sha256.Sum256([]byte(b))
return subtle.ConstantTimeCompare(ha[:], hb[:]) == 1
}
// ValidSubscribeTokenShape reports whether s looks like a token this service
// issues: base64url alphabet, between 16 and 64 characters. It is a cheap
// pre-filter for untrusted input (QR payloads, query strings), not a substitute
// for EqualToken.
func ValidSubscribeTokenShape(s string) bool {
if len(s) < 16 || len(s) > 64 {
return false
}
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case c >= 'A' && c <= 'Z', c >= 'a' && c <= 'z', c >= '0' && c <= '9', c == '-', c == '_':
default:
return false
}
}
return true
}
+133
View File
@@ -0,0 +1,133 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package alarmtoken
import (
"bytes"
"encoding/base64"
"strings"
"testing"
)
func TestNewTokensAreDistinctAndWellShaped(t *testing.T) {
seen := make(map[string]bool)
for i := 0; i < 64; i++ {
admin, err := NewAdminToken()
if err != nil {
t.Fatalf("NewAdminToken: %v", err)
}
sub, err := NewSubscribeToken()
if err != nil {
t.Fatalf("NewSubscribeToken: %v", err)
}
if seen[admin] || seen[sub] {
t.Fatalf("duplicate token generated")
}
seen[admin], seen[sub] = true, true
raw, err := base64.RawURLEncoding.DecodeString(admin)
if err != nil {
t.Fatalf("admin token is not raw-url base64: %v", err)
}
if len(raw) != AdminTokenBytes {
t.Fatalf("admin token entropy = %d bytes, want %d", len(raw), AdminTokenBytes)
}
raw, err = base64.RawURLEncoding.DecodeString(sub)
if err != nil {
t.Fatalf("subscribe token is not raw-url base64: %v", err)
}
if len(raw) != SubscribeTokenBytes {
t.Fatalf("subscribe token entropy = %d bytes, want %d", len(raw), SubscribeTokenBytes)
}
if !ValidSubscribeTokenShape(sub) {
t.Fatalf("freshly generated subscribe token %q fails its own shape check", sub)
}
}
}
func TestHashAndEqualHash(t *testing.T) {
token, err := NewAdminToken()
if err != nil {
t.Fatalf("NewAdminToken: %v", err)
}
digest := Hash(token)
if len(digest) != HashBytes {
t.Fatalf("Hash length = %d, want %d", len(digest), HashBytes)
}
if !bytes.Equal(digest, Hash(token)) {
t.Fatalf("Hash is not deterministic")
}
if !EqualHash(digest, token) {
t.Fatalf("EqualHash rejected the token it was computed from")
}
other, err := NewAdminToken()
if err != nil {
t.Fatalf("NewAdminToken: %v", err)
}
if EqualHash(digest, other) {
t.Fatalf("EqualHash accepted a different token")
}
// A malformed (short) stored digest must never compare equal.
if EqualHash(digest[:HashBytes-1], token) {
t.Fatalf("EqualHash accepted a truncated digest")
}
if EqualHash(nil, token) {
t.Fatalf("EqualHash accepted an empty digest")
}
}
func TestEqualToken(t *testing.T) {
a, _ := NewSubscribeToken()
b, _ := NewSubscribeToken()
if !EqualToken(a, a) {
t.Fatalf("EqualToken rejected identical tokens")
}
if EqualToken(a, b) {
t.Fatalf("EqualToken accepted different tokens")
}
// Different lengths must compare unequal, not panic.
if EqualToken(a, a+"x") {
t.Fatalf("EqualToken accepted a longer token")
}
if !EqualToken("", "") {
t.Fatalf("EqualToken rejected two empty strings")
}
}
func TestValidSubscribeTokenShape(t *testing.T) {
cases := []struct {
in string
want bool
}{
{"", false},
{"short", false},
{"0123456789abcde", false}, // 15 chars -> too short
{"0123456789abcdef", true}, // 16 chars, alphabet ok
{strings.Repeat("a", 64), true}, // max length
{strings.Repeat("a", 65), false}, // too long
{"0123456789abcde!", false}, // illegal character
{"0123456789abcde/", false}, // not url-safe
{"0123456789abcde ", false}, // whitespace
{"0123456789abcdeé", false}, // non-ASCII
{"AAAA0123456789-_abcdef", true}, // - and _ are legal
{"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJ", true},
}
for _, tc := range cases {
if got := ValidSubscribeTokenShape(tc.in); got != tc.want {
t.Errorf("ValidSubscribeTokenShape(%q) = %v, want %v", tc.in, got, tc.want)
}
}
}
+100
View File
@@ -0,0 +1,100 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// Package alertstate holds the alarm state machine as pure functions, so the
// transitions are unit-testable without a database.
//
// Frozen v1 semantics:
//
// - `status` is the last COMMANDED state (armed | triggered | disarmed).
// - `active` is DERIVED: a triggered alert stops being active once its
// auto-stop window elapses, with no background job and no timer. That is
// what makes auto-stop correct across several service instances.
package alertstate
import "time"
// Commanded statuses.
const (
StatusArmed = "armed"
StatusTriggered = "triggered"
StatusDisarmed = "disarmed"
)
// Event kinds (also the wire `action` values).
const (
KindTrigger = "trigger"
KindDisarm = "disarm"
)
// MaxAutoStopSeconds bounds auto_stop_seconds (mirrors the SQL CHECK).
const MaxAutoStopSeconds = 86400
// ValidStatus reports whether s is one of the three commanded statuses.
func ValidStatus(s string) bool {
switch s {
case StatusArmed, StatusTriggered, StatusDisarmed:
return true
default:
return false
}
}
// ValidKind reports whether k is a valid event kind.
func ValidKind(k string) bool {
return k == KindTrigger || k == KindDisarm
}
// IsActive reports whether the alert is live at now.
//
// A never-triggered alert (nil triggeredAt) is never active. autoStopSeconds
// == 0 means "ring until disarmed"; a positive value bounds the window. The
// window is inclusive of its start and exclusive at its end.
func IsActive(status string, triggeredAt *time.Time, autoStopSeconds int, now time.Time) bool {
if status != StatusTriggered || triggeredAt == nil {
return false
}
if autoStopSeconds <= 0 {
return true
}
return now.Before(triggeredAt.Add(time.Duration(autoStopSeconds) * time.Second))
}
// RemainingAutoStopSeconds returns how much of the auto-stop window is left at
// now, or 0 when the alert is not bounded or has already elapsed. It is what
// alarmStatus reports so a client can run its own countdown.
func RemainingAutoStopSeconds(status string, triggeredAt *time.Time, autoStopSeconds int, now time.Time) int {
if !IsActive(status, triggeredAt, autoStopSeconds, now) || autoStopSeconds <= 0 {
return 0
}
left := int(triggeredAt.Add(time.Duration(autoStopSeconds) * time.Second).Sub(now).Seconds())
if left < 0 {
return 0
}
return left
}
// CanDisarm reports whether a disarm command is legal for the current status.
// Disarming an alert that was never triggered (or is already disarmed) is a
// state error, not a no-op, so the client learns its view is stale.
func CanDisarm(status string) bool {
return status == StatusTriggered
}
// CanDelete reports whether the alert may be deleted. A live alarm must be
// disarmed first: deleting it would silently orphan every ringing client.
func CanDelete(active bool) bool {
return !active
}
+105
View File
@@ -0,0 +1,105 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package alertstate
import (
"testing"
"time"
)
func TestValidStatusAndKind(t *testing.T) {
for _, s := range []string{StatusArmed, StatusTriggered, StatusDisarmed} {
if !ValidStatus(s) {
t.Errorf("ValidStatus(%q) = false", s)
}
}
for _, s := range []string{"", "ARMED", "firing", "trigger"} {
if ValidStatus(s) {
t.Errorf("ValidStatus(%q) = true, want false", s)
}
}
for _, k := range []string{KindTrigger, KindDisarm} {
if !ValidKind(k) {
t.Errorf("ValidKind(%q) = false", k)
}
}
for _, k := range []string{"", "TRIGGER", "alarm", "stop"} {
if ValidKind(k) {
t.Errorf("ValidKind(%q) = true, want false", k)
}
}
}
func TestIsActive(t *testing.T) {
now := time.Date(2026, 10, 18, 12, 0, 0, 0, time.UTC)
earlier := now.Add(-10 * time.Minute)
cases := []struct {
name string
status string
at *time.Time
auto int
want bool
}{
{"armed is never active", StatusArmed, &earlier, 0, false},
{"disarmed is never active", StatusDisarmed, &earlier, 0, false},
{"triggered without auto-stop stays active", StatusTriggered, &earlier, 0, true},
{"triggered with auto-stop still inside the window", StatusTriggered, &earlier, 3600, true},
{"triggered with auto-stop past the window", StatusTriggered, &earlier, 60, false},
{"triggered with no timestamp", StatusTriggered, nil, 0, false},
{"unknown status", "firing", &earlier, 0, false},
}
for _, tc := range cases {
if got := IsActive(tc.status, tc.at, tc.auto, now); got != tc.want {
t.Errorf("%s: IsActive = %v, want %v", tc.name, got, tc.want)
}
}
}
func TestRemainingAutoStopSeconds(t *testing.T) {
now := time.Date(2026, 10, 18, 12, 0, 0, 0, time.UTC)
at := now.Add(-10 * time.Minute)
if got := RemainingAutoStopSeconds(StatusTriggered, &at, 3600, now); got != 3000 {
t.Errorf("remaining = %d, want 3000", got)
}
if got := RemainingAutoStopSeconds(StatusTriggered, &at, 60, now); got != 0 {
t.Errorf("expired remaining = %d, want 0", got)
}
if got := RemainingAutoStopSeconds(StatusArmed, &at, 3600, now); got != 0 {
t.Errorf("non-triggered remaining = %d, want 0", got)
}
if got := RemainingAutoStopSeconds(StatusTriggered, &at, 0, now); got != 0 {
t.Errorf("auto-stop 0 remaining = %d, want 0", got)
}
}
func TestCanDisarmAndDelete(t *testing.T) {
if !CanDisarm(StatusTriggered) {
t.Errorf("CanDisarm(triggered) = false")
}
for _, s := range []string{StatusArmed, StatusDisarmed, ""} {
if CanDisarm(s) {
t.Errorf("CanDisarm(%q) = true, want false", s)
}
}
if CanDelete(true) {
t.Errorf("CanDelete(active) = true, want false")
}
if !CanDelete(false) {
t.Errorf("CanDelete(inactive) = false, want true")
}
}
+34
View File
@@ -0,0 +1,34 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// Package apierr holds the frozen v1 wire error codes.
//
// They live in their own package so the HTTP action layer and the WebSocket
// layer (which cannot import each other without a cycle) agree on one source of
// truth. These strings are part of the published contract: never rename one
// without a contract revision.
package apierr
const (
AlertNotFound = "ERR_ALERT_NOT_FOUND"
AlertTokenInvalid = "ERR_ALERT_TOKEN_INVALID"
AlertNotAdmin = "ERR_ALERT_NOT_ADMIN"
AlertAlreadyJoined = "ERR_ALERT_ALREADY_JOINED"
AlertNotSubscribed = "ERR_ALERT_NOT_SUBSCRIBED"
AlertState = "ERR_ALERT_STATE"
Validation = "ERR_VALIDATION"
Unauthorized = "ERR_UNAUTHORIZED"
Internal = "ERR_INTERNAL"
)
+87
View File
@@ -0,0 +1,87 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package qr
import (
"bytes"
"errors"
"image"
"image/color"
"image/png"
)
// Render limits. They bound the memory a single unauthenticated-ish request can
// allocate (a 64px module on a 57-module matrix is ~13 MB).
const (
MaxScale = 64
MaxQuietZone = 32
// MinQuietZone is the quiet zone ISO/IEC 18004 requires (4 modules on each
// side). Below it a decoder legitimately fails to locate the symbol —
// measured: zbarimg exits 4 with "no symbol" at 0..2 modules — so this
// package never renders an unscannable PNG.
MinQuietZone = 4
)
// ErrBadMatrix reports a matrix that is not a non-empty square.
var ErrBadMatrix = errors.New("qr: matrix must be a non-empty square")
// PNG renders a module matrix as a PNG image. Each module becomes scale×scale
// pixels and the image is surrounded by a quietZone-module light border, which
// scanners require.
func PNG(matrix [][]bool, scale, quietZone int) ([]byte, error) {
size := len(matrix)
if size == 0 {
return nil, ErrBadMatrix
}
for _, row := range matrix {
if len(row) != size {
return nil, ErrBadMatrix
}
}
if scale < 1 || scale > MaxScale {
return nil, errors.New("qr: scale out of range")
}
if quietZone < MinQuietZone || quietZone > MaxQuietZone {
return nil, errors.New("qr: quiet zone out of range (minimum 4 modules)")
}
side := (size + 2*quietZone) * scale
img := image.NewPaletted(image.Rect(0, 0, side, side),
color.Palette{color.White, color.Black})
for y := 0; y < size; y++ {
for x := 0; x < size; x++ {
if !matrix[y][x] {
continue
}
x0 := (x + quietZone) * scale
y0 := (y + quietZone) * scale
for dy := 0; dy < scale; dy++ {
row := img.PixOffset(x0, y0+dy)
for dx := 0; dx < scale; dx++ {
img.Pix[row+dx] = 1
}
}
}
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
+77
View File
@@ -0,0 +1,77 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// Package qr encodes a string as a QR symbol and renders it as a PNG.
//
// The symbol is produced by github.com/boombuler/barcode/qr. This package only
// fixes the encoding profile the Alarm service needs — error-correction level
// M, automatic mode selection, and a row-major [][]bool module matrix (true =
// dark, no quiet zone) — and renders that matrix with the quiet zone scanners
// require (png.go).
//
// Why a dependency instead of the in-repo encoder that lived here: that encoder
// stored its Reed-Solomon generator polynomial in the wrong coefficient order,
// so every ECC block it computed was wrong and no decoder could read its output.
// Verified two ways: extracting the codeword stream from a libqrencode reference
// symbol (the data codewords matched, the ECC bytes did not, and a generator
// derived from its roots reproduced the reference ECC exactly), and zbarimg
// returning "no symbol" for its PNGs at every quiet zone. A QR encoder a real
// decoder cannot read is worthless, and re-deriving Reed-Solomon, placement and
// mask selection is not worth ~690 lines in a service repo. The library is
// pinned in go.mod and its output is verified by a zbarimg round-trip in
// qr_test.go.
package qr
import (
"fmt"
"image/color"
"github.com/boombuler/barcode"
barcodeqr "github.com/boombuler/barcode/qr"
)
// Encode encodes data as a QR symbol and returns its module matrix.
//
// The error-correction level is M (~15%) and the encoding mode is chosen
// automatically by the library (a URL with lowercase letters and a '#' lands in
// byte mode). The returned matrix has no quiet zone; PNG adds it.
func Encode(data []byte) ([][]bool, error) {
return EncodeString(string(data))
}
// EncodeString encodes s as a QR symbol and returns its module matrix.
func EncodeString(s string) ([][]bool, error) {
code, err := barcodeqr.Encode(s, barcodeqr.M, barcodeqr.Auto)
if err != nil {
return nil, fmt.Errorf("qr: encode: %w", err)
}
return moduleMatrix(code), nil
}
// moduleMatrix converts a one-pixel-per-module barcode image into a row-major
// boolean matrix.
func moduleMatrix(code barcode.Barcode) [][]bool {
b := code.Bounds()
matrix := make([][]bool, b.Dy())
for y := 0; y < b.Dy(); y++ {
row := make([]bool, b.Dx())
for x := 0; x < b.Dx(); x++ {
gray := color.GrayModel.Convert(code.At(b.Min.X+x, b.Min.Y+y)).(color.Gray)
row[x] = gray.Y < 128
}
matrix[y] = row
}
return matrix
}
+193
View File
@@ -0,0 +1,193 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package qr
import (
"bytes"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"git.dcntrlized.org/Dcntrlized/Alarm/internal/qrpayload"
"github.com/google/uuid"
)
// realPayload is the exact string the service puts in a QR code
// (https://alarm.dcntrlized.org/a/{alertId}#{subscribeToken}).
func realPayload() string {
return qrpayload.Build(
uuid.MustParse("6f1d0c4e-8a3b-4f2e-9c7d-2b5a1e0f9c33"),
"Zm9vYmFyYmF6cXV1eGNvcmdlZ3JhdWx0c3R1Yg",
)
}
func testPayloads() map[string]string {
return map[string]string{
"short": "ALARM",
"real": realPayload(),
"long": strings.Repeat("a", 200),
}
}
// TestEncodeDecodeRoundTrip is the test that matters: a real decoder must read
// what this package produces. It renders the PNG exactly as the qrImage action
// does (scale 8, quiet zone 4) and shells out to zbarimg.
//
// This test is skipped when zbarimg is not installed. It must never be replaced
// by a golden-matrix comparison: the golden-matrix tests it replaces passed
// while the previous in-repo encoder emitted symbols no decoder could read.
func TestEncodeDecodeRoundTrip(t *testing.T) {
zbarimg, err := exec.LookPath("zbarimg")
if err != nil {
t.Skip("zbarimg not installed; skipping QR decode round-trip")
}
dir := t.TempDir()
for name, payload := range testPayloads() {
t.Run(name, func(t *testing.T) {
matrix, err := EncodeString(payload)
if err != nil {
t.Fatalf("EncodeString: %v", err)
}
pngBytes, err := PNG(matrix, 8, MinQuietZone)
if err != nil {
t.Fatalf("PNG: %v", err)
}
path := filepath.Join(dir, name+".png")
if err := os.WriteFile(path, pngBytes, 0o600); err != nil {
t.Fatalf("write png: %v", err)
}
out, err := exec.Command(zbarimg, "--raw", "-q", path).CombinedOutput()
if err != nil {
t.Fatalf("zbarimg could not decode the symbol (%v): %s", err, bytes.TrimSpace(out))
}
got := strings.TrimRight(string(out), "\r\n")
if got != payload {
t.Fatalf("decoded payload mismatch:\n got: %q\nwant: %q", got, payload)
}
})
}
}
// TestEncodeMatchesEncodeString pins that the []byte and string entry points
// are the same encoding.
func TestEncodeMatchesEncodeString(t *testing.T) {
payload := realPayload()
fromString, err := EncodeString(payload)
if err != nil {
t.Fatalf("EncodeString: %v", err)
}
fromBytes, err := Encode([]byte(payload))
if err != nil {
t.Fatalf("Encode: %v", err)
}
if len(fromString) != len(fromBytes) {
t.Fatalf("matrix sizes differ: %d vs %d", len(fromString), len(fromBytes))
}
for y := range fromString {
for x := range fromString[y] {
if fromString[y][x] != fromBytes[y][x] {
t.Fatalf("module (%d,%d) differs between Encode and EncodeString", y, x)
}
}
}
}
// TestMatrixShape checks the structural invariants of a QR symbol: square,
// odd-sized, and carrying the three finder patterns.
func TestMatrixShape(t *testing.T) {
matrix, err := EncodeString(realPayload())
if err != nil {
t.Fatalf("EncodeString: %v", err)
}
size := len(matrix)
if size == 0 || size%2 == 0 || (size-17)%4 != 0 {
t.Fatalf("not a valid QR size: %d", size)
}
for y, row := range matrix {
if len(row) != size {
t.Fatalf("row %d has %d modules, want %d", y, len(row), size)
}
}
for _, origin := range [][2]int{{0, 0}, {0, size - 7}, {size - 7, 0}} {
if !hasFinder(matrix, origin[0], origin[1]) {
t.Fatalf("missing finder pattern at (%d,%d)", origin[0], origin[1])
}
}
}
func hasFinder(matrix [][]bool, r0, c0 int) bool {
want := [7][7]bool{
{true, true, true, true, true, true, true},
{true, false, false, false, false, false, true},
{true, false, true, true, true, false, true},
{true, false, true, true, true, false, true},
{true, false, true, true, true, false, true},
{true, false, false, false, false, false, true},
{true, true, true, true, true, true, true},
}
for dy := 0; dy < 7; dy++ {
for dx := 0; dx < 7; dx++ {
if matrix[r0+dy][c0+dx] != want[dy][dx] {
return false
}
}
}
return true
}
// TestPNGGeometry checks the output format and the quiet-zone floor.
func TestPNGGeometry(t *testing.T) {
matrix, err := EncodeString(realPayload())
if err != nil {
t.Fatalf("EncodeString: %v", err)
}
const scale, quiet = 8, 4
pngBytes, err := PNG(matrix, scale, quiet)
if err != nil {
t.Fatalf("PNG: %v", err)
}
signature := []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}
if !bytes.HasPrefix(pngBytes, signature) {
t.Fatal("output is not a PNG")
}
for _, quietZone := range []int{0, 1, 3} {
if _, err := PNG(matrix, scale, quietZone); err == nil {
t.Fatalf("PNG accepted quiet zone %d below the 4-module minimum", quietZone)
}
}
if _, err := PNG(matrix, 0, quiet); err == nil {
t.Fatal("PNG accepted scale 0")
}
if _, err := PNG(nil, scale, quiet); err == nil {
t.Fatal("PNG accepted an empty matrix")
}
if _, err := PNG([][]bool{{true, false}}, scale, quiet); err == nil {
t.Fatal("PNG accepted a non-square matrix")
}
}
// TestEncodeRejectsOversized pins that content which cannot fit in any QR
// version surfaces as an error instead of a truncated symbol.
func TestEncodeRejectsOversized(t *testing.T) {
if _, err := EncodeString(strings.Repeat("x", 4000)); err == nil {
t.Fatal("expected an error for content beyond QR capacity")
}
}
+95
View File
@@ -0,0 +1,95 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package qrpayload
import (
"errors"
"strings"
"testing"
"github.com/google/uuid"
)
func TestBuildParseRoundTrip(t *testing.T) {
alertID := uuid.New()
token := "AbCdEf0123456789_-xyzABC"
raw := Build(alertID, token)
if !strings.HasPrefix(raw, "https://"+PublicHost+PathPrefix) {
t.Fatalf("Build = %q, want the canonical https form", raw)
}
gotID, gotToken, err := Parse(raw)
if err != nil {
t.Fatalf("Parse(Build(...)): %v", err)
}
if gotID != alertID {
t.Fatalf("alert id = %s, want %s", gotID, alertID)
}
if gotToken != token {
t.Fatalf("token = %q, want %q", gotToken, token)
}
}
func TestBuildSchemeParseRoundTrip(t *testing.T) {
alertID := uuid.New()
token := "AbCdEf0123456789_-xyzABC"
raw := BuildScheme(alertID, token)
if !strings.HasPrefix(raw, Scheme+"://"+SchemeHost+"?") {
t.Fatalf("BuildScheme = %q, want the app-scheme form", raw)
}
gotID, gotToken, err := Parse(raw)
if err != nil {
t.Fatalf("Parse(BuildScheme(...)): %v", err)
}
if gotID != alertID || gotToken != token {
t.Fatalf("round-trip = (%s, %q), want (%s, %q)", gotID, gotToken, alertID, token)
}
}
func TestParseRejectsMalformed(t *testing.T) {
alertID := uuid.New()
cases := []string{
"",
" ",
"not a url at all",
"ftp://" + PublicHost + PathPrefix + alertID.String() + "#token",
"https://" + PublicHost + PathPrefix + alertID.String(), // no fragment
"https://" + PublicHost + "/b/" + alertID.String() + "#token",
"https://" + PublicHost + PathPrefix + "not-a-uuid#token",
"https://" + PublicHost + PathPrefix + alertID.String() + "#",
"https://" + PublicHost + PathPrefix + alertID.String() + "#short",
"dcntrlized-alarm://join?alert=nope&token=AbCdEf0123456789",
"dcntrlized-alarm://join?token=AbCdEf0123456789",
}
for _, in := range cases {
if _, _, err := Parse(in); err == nil {
t.Errorf("Parse(%q) succeeded, want an error", in)
} else if !errors.Is(err, ErrInvalidPayload) {
t.Errorf("Parse(%q) error = %v, want it to wrap ErrInvalidPayload", in, err)
}
}
}
func TestParseTrimsSurroundingSpace(t *testing.T) {
alertID := uuid.New()
token := "AbCdEf0123456789_-xyzABC"
gotID, gotToken, err := Parse(" " + Build(alertID, token) + "\n")
if err != nil {
t.Fatalf("Parse with surrounding space: %v", err)
}
if gotID != alertID || gotToken != token {
t.Fatalf("round-trip = (%s, %q)", gotID, gotToken)
}
}
+153
View File
@@ -0,0 +1,153 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// Package qrpayload is the single source of truth for the alarm QR payload.
//
// The frozen v1 wire form is
//
// https://alarm.dcntrlized.org/a/{alertId}#{subscribeToken}
//
// The fragment (not the query string) carries the token so the secret is never
// sent to the host if a scanner ever resolves the URL in a browser. Build is
// used by createAlert/getAlert/rotateSubscribeToken, and Parse is used by any
// component that accepts a scanned payload (the Android client's scanner and
// the service's own tests).
//
// Parse additionally accepts the app-scheme form
//
// dcntrlized-alarm://join?alert={alertId}&token={subscribeToken}
//
// which the Android client can register as a deep link, and it is deliberately
// lenient about the https host: the host grants nothing, the alert id and the
// subscribe token are the only things that matter, and a self-hosted instance
// may legitimately hand out its own hostname.
package qrpayload
import (
"errors"
"fmt"
"net/url"
"strings"
"git.dcntrlized.org/Dcntrlized/Alarm/internal/alarmtoken"
"github.com/google/uuid"
)
const (
// PublicHost is the canonical host in the QR payload.
PublicHost = "alarm.dcntrlized.org"
// PathPrefix is the canonical path prefix ("/a/" + alert id).
PathPrefix = "/a/"
// Scheme is the custom deep-link scheme.
Scheme = "dcntrlized-alarm"
// SchemeHost is the custom deep-link host ("join").
SchemeHost = "join"
)
// ErrInvalidPayload is returned by Parse for anything that is not a well-formed
// alarm payload. Callers answer ERR_VALIDATION.
var ErrInvalidPayload = errors.New("qrpayload: invalid alarm payload")
// Build renders the canonical QR payload for an alert.
func Build(alertID uuid.UUID, subscribeToken string) string {
return "https://" + PublicHost + PathPrefix + alertID.String() + "#" + subscribeToken
}
// BuildScheme renders the app-scheme deep link for the same alert.
func BuildScheme(alertID uuid.UUID, subscribeToken string) string {
return Scheme + "://" + SchemeHost + "?alert=" + alertID.String() + "&token=" + url.QueryEscape(subscribeToken)
}
// Parse extracts the alert id and subscribe token from a scanned payload. It
// accepts the canonical https form and the app-scheme form.
func Parse(raw string) (uuid.UUID, string, error) {
var zero uuid.UUID
s := strings.TrimSpace(raw)
if s == "" {
return zero, "", fmt.Errorf("%w: empty payload", ErrInvalidPayload)
}
// A scanner may hand back the payload with the fragment stripped (some
// scanners normalise URLs); the canonical form always has a non-empty
// fragment, so an empty one is a hard error below.
u, err := url.Parse(s)
if err != nil {
return zero, "", fmt.Errorf("%w: unparseable url", ErrInvalidPayload)
}
switch strings.ToLower(u.Scheme) {
case "https", "http":
return parseWeb(u)
case Scheme:
return parseScheme(u)
default:
return zero, "", fmt.Errorf("%w: unsupported scheme %q", ErrInvalidPayload, u.Scheme)
}
}
func parseWeb(u *url.URL) (uuid.UUID, string, error) {
var zero uuid.UUID
if u.Host == "" {
return zero, "", fmt.Errorf("%w: missing host", ErrInvalidPayload)
}
path := u.EscapedPath()
if !strings.HasPrefix(path, PathPrefix) {
return zero, "", fmt.Errorf("%w: path must start with %q", ErrInvalidPayload, PathPrefix)
}
rawID := strings.TrimPrefix(path, PathPrefix)
// The token rides the fragment; reject anything with a trailing path
// segment or a query string in the id slot.
if rawID == "" || strings.ContainsAny(rawID, "/?#") {
return zero, "", fmt.Errorf("%w: malformed alert id", ErrInvalidPayload)
}
alertID, err := uuid.Parse(rawID)
if err != nil {
return zero, "", fmt.Errorf("%w: alert id is not a uuid", ErrInvalidPayload)
}
token := u.Fragment
if err := validateToken(token); err != nil {
return zero, "", err
}
return alertID, token, nil
}
func parseScheme(u *url.URL) (uuid.UUID, string, error) {
var zero uuid.UUID
if !strings.EqualFold(u.Host, SchemeHost) {
return zero, "", fmt.Errorf("%w: scheme host must be %q", ErrInvalidPayload, SchemeHost)
}
q := u.Query()
alertID, err := uuid.Parse(q.Get("alert"))
if err != nil {
return zero, "", fmt.Errorf("%w: alert id is not a uuid", ErrInvalidPayload)
}
token := q.Get("token")
if err := validateToken(token); err != nil {
return zero, "", err
}
return alertID, token, nil
}
func validateToken(token string) error {
if token == "" {
return fmt.Errorf("%w: missing subscribe token", ErrInvalidPayload)
}
if !alarmtoken.ValidSubscribeTokenShape(token) {
return fmt.Errorf("%w: malformed subscribe token", ErrInvalidPayload)
}
return nil
}
+167
View File
@@ -0,0 +1,167 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// Package logger is the service-wide leveled logger (house pattern: KeepNote,
// Calendar). Info/Warn/Debug go to stdout (plus a file under logs/ when the
// working directory is writable); Error goes to stderr and resolves the true
// call site even through registered log-helper wrappers.
//
// It never logs secrets on its own: callers must not pass admin tokens,
// subscribe tokens or credentials into a format string.
package logger
import (
"fmt"
"io"
"log"
"os"
"path/filepath"
"runtime"
"sync"
"time"
)
var (
infoLogger *log.Logger
warnLogger *log.Logger
errorLogger *log.Logger
debugLogger *log.Logger
once sync.Once
)
// helperFuncs tracks log entry points and any registered wrapper functions
// (e.g. git.dcntrlized.org/Dcntrlized/Alarm/actions.Debug) so callerDepth
// can skip them and report the true call site.
var helperFuncs = map[string]bool{}
func init() {
for _, name := range []string{
"git.dcntrlized.org/Dcntrlized/Alarm/logger.Info",
"git.dcntrlized.org/Dcntrlized/Alarm/logger.Warn",
"git.dcntrlized.org/Dcntrlized/Alarm/logger.Error",
"git.dcntrlized.org/Dcntrlized/Alarm/logger.Debug",
} {
helperFuncs[name] = true
}
}
func initLogger() {
if err := os.MkdirAll("logs", 0755); err != nil {
log.Printf("Failed to create logs directory: %v. Falling back to standard output.", err)
}
// 1. Setup Info Logger
var infoOutput io.Writer = os.Stdout
if infoFile, err := os.OpenFile("logs/info.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666); err == nil {
infoOutput = io.MultiWriter(os.Stdout, infoFile)
writeHeader(infoFile)
}
infoLogger = log.New(infoOutput, "INFO: ", log.Ldate|log.Ltime|log.LUTC)
// 2. Setup Warn Logger
var warnOutput io.Writer = os.Stdout
if warnFile, err := os.OpenFile("logs/warn.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666); err == nil {
warnOutput = io.MultiWriter(os.Stdout, warnFile)
writeHeader(warnFile)
}
warnLogger = log.New(warnOutput, "WARN: ", log.Ldate|log.Ltime|log.LUTC)
// 3. Setup Error Logger
var errorOutput io.Writer = os.Stderr
if errorFile, err := os.OpenFile("logs/error.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666); err == nil {
errorOutput = io.MultiWriter(os.Stderr, errorFile)
writeHeader(errorFile)
}
// The error logger does not rely on Lshortfile; Error resolves the true
// call site itself via callSite, so line numbers stay correct even through
// log helper wrappers.
errorLogger = log.New(errorOutput, "ERROR: ", log.Ldate|log.Ltime|log.LUTC)
// 4. Setup Debug Logger
var debugOutput io.Writer = os.Stdout
if debugFile, err := os.OpenFile("logs/debug.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666); err == nil {
debugOutput = io.MultiWriter(os.Stdout, debugFile)
writeHeader(debugFile)
}
debugLogger = log.New(debugOutput, "DEBUG: ", log.Ldate|log.Ltime|log.LUTC)
}
func writeHeader(file *os.File) {
file.WriteString("========== Start Log ==========\n")
file.WriteString("[INFO] Logger initialized\n")
file.WriteString("[" + time.Now().Format(time.RFC3339) + "]\n")
}
// RegisterLogHelper registers a wrapper function around this package's log
// functions so that call sites logged through it point at the true caller
// instead of the wrapper. Pass the fully qualified function name, e.g.
// "git.dcntrlized.org/Dcntrlized/Alarm/actions.Debug".
func RegisterLogHelper(fullFuncName string) {
helperFuncs[fullFuncName] = true
}
func isLogHelper(name string) bool {
return helperFuncs[name]
}
// callSite walks the call stack, skipping this package's entry points and any
// registered log helpers, and returns the file:line of the true call site.
//
//go:noinline
func callSite() (string, int) {
pcs := make([]uintptr, 64)
n := runtime.Callers(2, pcs)
frames := runtime.CallersFrames(pcs[:n])
for {
frame, more := frames.Next()
if !more {
return "???", 0
}
if isLogHelper(frame.Function) {
continue
}
return frame.File, frame.Line
}
}
// Info accepts a format string and an optional, arbitrary number of arguments.
func Info(format string, v ...any) {
once.Do(initLogger)
infoLogger.Printf(format, v...)
}
// Warn accepts a format string and an optional, arbitrary number of arguments.
func Warn(format string, v ...any) {
once.Do(initLogger)
warnLogger.Printf(format, v...)
}
// Error accepts a format string and an optional, arbitrary number of arguments.
// The reported file:line points at the true call site, even through registered
// log helper wrappers.
//
//go:noinline
func Error(format string, v ...any) {
once.Do(initLogger)
file, line := callSite()
errorLogger.Output(0, fmt.Sprintf("%s:%d: %s", filepath.Base(file), line, fmt.Sprintf(format, v...)))
}
// Debug accepts a format string and an optional, arbitrary number of arguments.
func Debug(format string, v ...any) {
once.Do(initLogger)
debugLogger.Printf(format, v...)
}
+239
View File
@@ -0,0 +1,239 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// Command alarm is the Dcntrlized alarm/alert service. It owns alerts, their
// admin/subscribe tokens and the live trigger fan-out; identity comes from the
// shared dcntrlized-auth service and storage from the shared dcntrlized-db.
//
// API shape (frozen v1 contract): GET /_manifest, the raw /auth/* proxies,
// POST/GET /action/<name> ({"payload": …} envelope, X-Access-Token required)
// and the GET /ws/alarm WebSocket. Everything rides ONE plaintext :8080
// listener (contract rev 9 §B): no second socket, no TLS, no /config edge.
package main
import (
"context"
"net"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"git.dcntrlized.org/Dcntrlized/Alarm/db"
"git.dcntrlized.org/Dcntrlized/Alarm/handlers/actions"
"git.dcntrlized.org/Dcntrlized/Alarm/handlers/auth"
"git.dcntrlized.org/Dcntrlized/Alarm/handlers/manifest"
"git.dcntrlized.org/Dcntrlized/Alarm/logger"
"git.dcntrlized.org/Dcntrlized/Alarm/middleware"
"git.dcntrlized.org/Dcntrlized/Alarm/realtime"
"git.dcntrlized.org/dcntrlized/dcntrlized-auth/authclient"
"git.dcntrlized.org/dcntrlized/dcntrlized-auth/authproxy"
)
// servicePort is the service's ONLY listening port. The API, the raw auth
// proxies and the /ws/alarm WebSocket all share it.
const servicePort = "8080"
func main() {
// Root context cancelled on SIGINT/SIGTERM. The realtime hub (task-4) is
// owned by it, so a deploy drains subscribers instead of cutting them off.
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
// Shared-auth consumer: authclient validates RS256 access tokens against
// the published key; authproxy.Target() resolves AUTH_SERVICE_URL
// (http://dcntrlized-auth:8080 on dcntrlized_net).
if err := authclient.Init(context.Background(), authproxy.Target()); err != nil {
logger.Error("failed to initialize auth client: %v", err)
}
logger.Info("Alarm service starting")
// The HTTP surface is built by newAPIMux/newOuterMux so main_test.go can
// construct exactly the same wiring and fail the gate on a route conflict:
// http.ServeMux panics on a duplicate pattern at registration time, which
// build/vet/gofmt cannot see.
apiMux := newAPIMux()
outer := newOuterMux(apiMux)
// --- task-2: the shared database pool ---
// Created here, not lazily on first request: newDB() pings the shared
// database for up to 30s to tolerate container start ordering, and that
// wait must not happen inside a request.
database := db.NewDB()
defer db.Close()
// --- task-4: the realtime layer ---
// Registers GET /ws/alarm on the outer mux (outside the logging/CORS
// chain), starts the hub + the DB notify bridge, and returns the drain func
// used in the shutdown path below.
realtimeShutdown := realtime.Register(outer, database)
// ONE plaintext listener (contract rev 9 §B). ReadHeaderTimeout/IdleTimeout
// cover slow-header and idle-socket abuse. ReadTimeout/WriteTimeout are
// deliberately ZERO: this listener also serves the long-lived /ws/alarm
// WebSocket, and a server-level read/write deadline is applied to the
// hijacked connection, which would tear live alarms down mid-stream. The
// WS layer owns its per-message deadlines (ping/pong + SetReadDeadline /
// SetWriteDeadline) instead.
server := &http.Server{
Handler: outer,
ReadHeaderTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
}
ln, err := net.Listen("tcp", ":"+servicePort)
if err != nil {
logger.Error("listen failed on :%s: %v", servicePort, err)
os.Exit(1)
}
serveErr := make(chan error, 1)
logger.Info("Alarm listening plaintext on :%s (single port: API + /ws/alarm)", servicePort)
go func() { serveErr <- server.Serve(ln) }()
select {
case <-ctx.Done():
logger.Info("signal received, shutting down")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
realtimeShutdown(shutdownCtx) // drains live subscribers before the HTTP server closes
_ = server.Shutdown(shutdownCtx)
case err := <-serveErr:
if err != nil && err != http.ErrServerClosed {
logger.Error("HTTP server failed: %v", err)
os.Exit(1)
}
}
}
// newAPIMux builds the API surface reached through the API chain
// (LoggingMiddleware(CorsMiddleware(apiMux))). The logging middleware
// deliberately does NOT log request bodies or query strings: every /action
// payload carries an adminToken or a subscribeToken (frozen contract) and those
// must never land in logs.
//
// House pattern (see Calendar/src/main.go): exactly ONE registration per path,
// with the handler dispatching on method. Registering the same pattern twice
// panics at startup, so /auth/logout — which serves GET (bodyless no-op) and
// POST (revoke the refresh token) — is a single method-aware registration.
func newAPIMux() *http.ServeMux {
apiMux := http.NewServeMux()
apiMux.HandleFunc("/_manifest", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
manifest.ManifestHandler(w, r)
})
apiMux.HandleFunc("/auth/delete", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
auth.AuthDeletePost(w, r, map[string]string{})
})
apiMux.HandleFunc("/auth/login", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
auth.AuthLoginPost(w, r, map[string]string{})
})
apiMux.HandleFunc("/auth/logout", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
auth.AuthLogoutGet(w, r, map[string]string{})
case http.MethodPost:
auth.AuthLogoutPost(w, r, map[string]string{})
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
})
apiMux.HandleFunc("/auth/me", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
auth.AuthMeGet(w, r, map[string]string{})
})
apiMux.HandleFunc("/auth/refresh", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
auth.AuthRefreshPost(w, r, map[string]string{})
})
apiMux.HandleFunc("/auth/register", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
auth.AuthRegisterPost(w, r, map[string]string{})
})
// /action/<name> dispatch is by HTTP method (GET/POST/PUT/DELETE); the
// switch contents live in handlers/actions (task-2 scope).
apiMux.HandleFunc("/action/", func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, "/action/") {
http.NotFound(w, r)
return
}
rest := strings.TrimPrefix(r.URL.Path, "/action/")
params := map[string]string{"actionName": rest}
switch r.Method {
case http.MethodDelete:
actions.ActionActionNameDelete(w, r, params)
return
case http.MethodGet:
actions.ActionActionNameGet(w, r, params)
return
case http.MethodPost:
actions.ActionActionNamePost(w, r, params)
return
case http.MethodPut:
actions.ActionActionNamePut(w, r, params)
return
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
})
return apiMux
}
// newOuterMux builds the single-listener mux. The API prefixes ride the API
// chain; /ws/alarm is registered separately by the realtime package (task-4)
// because a WebSocket upgrade must not be touched by the logging/CORS chain.
func newOuterMux(apiMux *http.ServeMux) *http.ServeMux {
outer := http.NewServeMux()
apiChain := middleware.LoggingMiddleware(middleware.CorsMiddleware(apiMux))
outer.Handle("/_manifest", apiChain)
outer.Handle("/auth/", apiChain)
outer.Handle("/action/", apiChain)
return outer
}
+109
View File
@@ -0,0 +1,109 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package main
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// TestRoutesConstruct builds exactly the wiring main() builds and fails on a
// panic. http.ServeMux panics at registration time when the same pattern is
// registered twice, which kills the process before it binds :8080 — and
// go build/vet/gofmt/go test cannot see it unless a test constructs the mux.
// This test exists because a duplicate /auth/logout registration shipped and
// only a real launch caught it.
func TestRoutesConstruct(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Fatalf("route registration panicked — the service would not boot: %v", r)
}
}()
apiMux := newAPIMux()
if apiMux == nil {
t.Fatal("newAPIMux returned nil")
}
outer := newOuterMux(apiMux)
if outer == nil {
t.Fatal("newOuterMux returned nil")
}
}
// TestWebSocketPatternIsFree proves main.go leaves /ws/alarm to the realtime
// layer: registering it on the outer mux must not conflict with anything
// main.go registered (realtime.Register does exactly this at boot).
func TestWebSocketPatternIsFree(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Fatalf("/ws/alarm conflicts with a pattern registered by main.go: %v", r)
}
}()
outer := newOuterMux(newAPIMux())
outer.Handle("/ws/alarm", http.NotFoundHandler())
}
// TestManifestThroughOuterChain serves GET /_manifest through the real outer
// mux (logging + CORS chain included), which is what a caller hits.
func TestManifestThroughOuterChain(t *testing.T) {
outer := newOuterMux(newAPIMux())
rec := httptest.NewRecorder()
outer.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/_manifest", nil))
if rec.Code != http.StatusOK {
t.Fatalf("GET /_manifest = %d, want 200 (body: %s)", rec.Code, rec.Body.String())
}
if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "application/json") {
t.Fatalf("GET /_manifest Content-Type = %q, want application/json", ct)
}
if body := rec.Body.String(); !strings.Contains(body, `"name": "Alarm"`) && !strings.Contains(body, `"name":"Alarm"`) {
t.Fatalf("GET /_manifest body does not name the service: %s", body)
}
}
// TestLogoutServesBothMethods pins the method-aware registration: GET (bodyless
// no-op) and POST (revoke the refresh token) must both be routed, and any other
// verb must be rejected with 405. The handler proxies to dcntrlized-auth, so the
// requests carry a short deadline: the assertion is about routing, and the test
// must not depend on (or wait for) a live auth service.
func TestLogoutServesBothMethods(t *testing.T) {
outer := newOuterMux(newAPIMux())
do := func(method string) int {
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
req := httptest.NewRequest(method, "/auth/logout", nil).WithContext(ctx)
rec := httptest.NewRecorder()
outer.ServeHTTP(rec, req)
return rec.Code
}
for _, method := range []string{http.MethodGet, http.MethodPost} {
if code := do(method); code == http.StatusMethodNotAllowed || code == http.StatusNotFound {
t.Fatalf("%s /auth/logout = %d, want the method to be routed", method, code)
}
}
if code := do(http.MethodPut); code != http.StatusMethodNotAllowed {
t.Fatalf("PUT /auth/logout = %d, want 405", code)
}
}
+44
View File
@@ -0,0 +1,44 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package middleware
import "net/http"
// CorsMiddleware is the house-pattern CORS shim for the RN clients (no cookie
// auth anywhere in the fleet, so echoing the Origin and allowing
// X-Access-Token is safe). It answers preflights directly.
func CorsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Dynamically echo the origin sent by the browser
origin := r.Header.Get("Origin")
if origin != "" {
w.Header().Set("Access-Control-Allow-Origin", origin)
} else {
w.Header().Set("Access-Control-Allow-Origin", "*")
}
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
// Note: Match the exact casing the browser requested, just to be safe
w.Header().Set("Access-Control-Allow-Headers", "Accept, Content-Type, Content-Length, Accept-Encoding, Authorization, X-Access-Token")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
next.ServeHTTP(w, r)
})
}
+104
View File
@@ -0,0 +1,104 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package middleware
import (
"bufio"
"net"
"net/http"
"strings"
"time"
"git.dcntrlized.org/Dcntrlized/Alarm/logger"
)
// LoggingMiddleware logs one line per API request: method, path, status,
// duration, user agent and client IP.
//
// It deliberately deviates from the Calendar/KeepNote house shim in ONE way:
// it never logs the request body or the query string. In Alarm every
// /action payload carries an adminToken or a subscribeToken, and the
// /ws/alarm upgrade carries the subscribe token in its query string, so the
// frozen contract's "adminToken is stored only as SHA-256" promise would be
// broken by writing them to logs/.
func LoggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
ip := r.RemoteAddr
if host, _, err := net.SplitHostPort(ip); err == nil {
ip = host
}
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
ip = strings.TrimSpace(strings.Split(forwarded, ",")[0])
}
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
logger.Info("%s request=%s %s path=%s status=%d ua=%q ip=%s duration=%s",
time.Now().Format(time.RFC3339),
r.Method,
r.Proto,
r.URL.Path,
rec.status,
r.UserAgent(),
ip,
time.Since(start),
)
})
}
// statusRecorder captures the response status without altering the response.
// It preserves http.Hijacker so the /ws/alarm upgrade still works when the
// route is mounted behind this middleware.
type statusRecorder struct {
http.ResponseWriter
status int
wroteHeader bool
}
func (s *statusRecorder) WriteHeader(code int) {
if !s.wroteHeader {
s.status = code
s.wroteHeader = true
}
s.ResponseWriter.WriteHeader(code)
}
func (s *statusRecorder) Write(b []byte) (int, error) {
if !s.wroteHeader {
s.wroteHeader = true
}
return s.ResponseWriter.Write(b)
}
// Hijack forwards to the wrapped ResponseWriter so a WebSocket upgrade can
// take over the connection.
func (s *statusRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) {
hj, ok := s.ResponseWriter.(http.Hijacker)
if !ok {
return nil, nil, http.ErrNotSupported
}
return hj.Hijack()
}
// Flush forwards to the wrapped ResponseWriter when it supports flushing.
func (s *statusRecorder) Flush() {
if f, ok := s.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
+148
View File
@@ -0,0 +1,148 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package realtime
import (
"errors"
"net/http"
"strings"
"git.dcntrlized.org/Dcntrlized/Alarm/db"
"git.dcntrlized.org/Dcntrlized/Alarm/internal/alarmtoken"
"git.dcntrlized.org/Dcntrlized/Alarm/internal/apierr"
"git.dcntrlized.org/Dcntrlized/Alarm/logger"
"git.dcntrlized.org/dcntrlized/dcntrlized-auth/authclient"
"github.com/google/uuid"
)
// authenticateWS resolves a GET /ws/alarm upgrade request:
//
// ?alertId=<uuid> required
// &token=<secret> required; the alert's admin token OR its subscribe token
// &deviceId=<id> optional; picks which device row is kept alive
//
// The caller's identity always comes from a verified X-Access-Token header. The
// access token may also be supplied as ?accessToken= / ?access_token= for
// clients (and debugging tools) that cannot set headers on the upgrade; that is
// a convenience, not a second trust path — the token is verified identically.
//
// A subscribe token authorises only a user who actually has a subscription on
// the alert, so knowing the shared token is not enough on its own. The admin
// token is only honoured for the user the alert was created by.
func authenticateWS(r *http.Request) (*Identity, error) {
access := strings.TrimSpace(r.Header.Get("X-Access-Token"))
if access == "" {
access = strings.TrimSpace(r.URL.Query().Get("accessToken"))
}
if access == "" {
access = strings.TrimSpace(r.URL.Query().Get("access_token"))
}
if access == "" {
return nil, &AuthError{
Status: http.StatusUnauthorized, Code: apierr.Unauthorized, Message: "missing access token",
}
}
authReq := r.Clone(r.Context())
authReq.Header.Set("X-Access-Token", access)
p, err := authclient.Authorize(authReq)
if err != nil {
logger.Warn("ws: access token rejected: %v", err)
return nil, &AuthError{
Status: http.StatusUnauthorized, Code: apierr.Unauthorized, Message: "invalid access token",
}
}
alertID, err := uuid.Parse(strings.TrimSpace(r.URL.Query().Get("alertId")))
if err != nil {
return nil, &AuthError{
Status: http.StatusBadRequest, Code: apierr.Validation, Message: "alertId is required",
}
}
token := strings.TrimSpace(r.URL.Query().Get("token"))
if token == "" {
return nil, &AuthError{
Status: http.StatusForbidden, Code: apierr.AlertTokenInvalid, Message: "token is required",
}
}
alert, err := db.GetAlert(r.Context(), alertID)
if err != nil {
if errors.Is(err, db.ErrNotFound) {
return nil, &AuthError{
Status: http.StatusNotFound, Code: apierr.AlertNotFound, Message: "alert not found",
}
}
logger.Error("ws: alert lookup failed: %v", err)
return nil, &AuthError{
Status: http.StatusInternalServerError, Code: apierr.Internal, Message: "internal error",
}
}
// Admin path: the alert's own admin token, presented by the alert's admin.
if alarmtoken.EqualHash(alert.AdminTokenHash, token) {
if alert.AdminUserID != p.ID {
logger.Warn("ws: user=%s presented the admin token of alert=%s without owning it", p.ID, alert.ID)
return nil, &AuthError{
Status: http.StatusForbidden, Code: apierr.AlertNotAdmin, Message: "caller is not the alert admin",
}
}
return &Identity{AlertID: alert.ID, UserID: p.ID, IsAdmin: true}, nil
}
// Subscriber path: the shared subscribe token plus a real subscription.
if !alarmtoken.EqualToken(alert.SubscribeToken, token) {
logger.Warn("ws: token mismatch alert=%s user=%s", alert.ID, p.ID)
return nil, &AuthError{
Status: http.StatusForbidden, Code: apierr.AlertTokenInvalid, Message: "invalid token",
}
}
sub, err := resolveSubscription(r, alert.ID, p.ID)
if err != nil {
if errors.Is(err, db.ErrNotFound) {
return nil, &AuthError{
Status: http.StatusNotFound, Code: apierr.AlertNotSubscribed, Message: "no subscription for this user on the alert",
}
}
logger.Error("ws: subscription lookup failed: %v", err)
return nil, &AuthError{
Status: http.StatusInternalServerError, Code: apierr.Internal, Message: "internal error",
}
}
return &Identity{
AlertID: alert.ID,
UserID: p.ID,
SubscriptionID: &sub.ID,
DeviceID: sub.DeviceID,
}, nil
}
// resolveSubscription picks the device row a connection keeps alive: the one
// named by ?deviceId= when given, otherwise the user's oldest subscription on
// the alert.
func resolveSubscription(r *http.Request, alertID, userID uuid.UUID) (*db.Subscription, error) {
if deviceID := strings.TrimSpace(r.URL.Query().Get("deviceId")); deviceID != "" {
return db.GetSubscriptionByDevice(r.Context(), alertID, userID, deviceID)
}
ids, err := db.UserSubscriptionIDs(r.Context(), alertID, userID)
if err != nil {
return nil, err
}
if len(ids) == 0 {
return nil, db.ErrNotFound
}
return db.GetSubscription(r.Context(), alertID, ids[0])
}
+156
View File
@@ -0,0 +1,156 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package realtime
import (
"encoding/json"
"sync"
"time"
"github.com/gorilla/websocket"
)
// client is one WebSocket connection. Reads and writes are owned by two
// separate goroutines (readPump/writePump); the only shared state is the
// buffered send channel and the close-once guard.
type client struct {
hub *Hub
conn *websocket.Conn
identity Identity
send chan []byte
done chan struct{}
closeOnce sync.Once
// ackMu guards acks; the counter is read by tests and by hub.Acks.
ackMu sync.Mutex
acks int
}
func newClient(h *Hub, conn *websocket.Conn, identity Identity) *client {
return &client{
hub: h,
conn: conn,
identity: identity,
send: make(chan []byte, h.opts.SendBuffer),
done: make(chan struct{}),
}
}
// trySend queues a frame without ever blocking. It reports false when the
// client's buffer is full (the caller then drops the client).
func (c *client) trySend(payload []byte) bool {
select {
case c.send <- payload:
return true
case <-c.done:
return false
default:
return false
}
}
// close signals both pumps to stop. It is idempotent.
func (c *client) close() {
c.closeOnce.Do(func() {
close(c.done)
})
}
func (c *client) touch() {
c.hub.touch(c)
}
// readPump owns all reads. The read deadline is refreshed by pongs, so a dead
// peer is detected within PongWait.
func (c *client) readPump() {
defer func() {
c.hub.unregister(c)
c.close()
_ = c.conn.Close()
}()
c.conn.SetReadLimit(c.hub.opts.MaxFrameBytes)
_ = c.conn.SetReadDeadline(time.Now().Add(c.hub.opts.PongWait))
c.conn.SetPongHandler(func(string) error {
_ = c.conn.SetReadDeadline(time.Now().Add(c.hub.opts.PongWait))
c.touch()
return nil
})
for {
_, data, err := c.conn.ReadMessage()
if err != nil {
return
}
var f ClientFrame
if err := json.Unmarshal(data, &f); err != nil {
// A malformed client frame is ignored, never fatal.
continue
}
switch f.Type {
case "ack":
c.ackMu.Lock()
c.acks++
c.ackMu.Unlock()
c.hub.recordAck(f.EventID)
case "ping":
c.touch()
// Answer at the protocol level: the frozen contract defines no
// JSON pong frame.
_ = c.conn.WriteControl(websocket.PongMessage, nil, time.Now().Add(c.hub.opts.WriteWait))
}
}
}
// writePump owns all writes except the WriteControl calls in readPump (which
// gorilla explicitly allows concurrently).
func (c *client) writePump() {
ticker := time.NewTicker(c.hub.opts.PingInterval)
defer func() {
ticker.Stop()
c.close()
_ = c.conn.Close()
}()
for {
select {
case payload := <-c.send:
_ = c.conn.SetWriteDeadline(time.Now().Add(c.hub.opts.WriteWait))
if err := c.conn.WriteMessage(websocket.TextMessage, payload); err != nil {
return
}
case <-ticker.C:
_ = c.conn.SetWriteDeadline(time.Now().Add(c.hub.opts.WriteWait))
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
c.touch()
case <-c.done:
_ = c.conn.SetWriteDeadline(time.Now().Add(c.hub.opts.WriteWait))
_ = c.conn.WriteMessage(websocket.CloseMessage,
websocket.FormatCloseMessage(websocket.CloseGoingAway, "server shutting down"))
return
}
}
}
// ackCount reports how many ack frames this connection sent.
func (c *client) ackCount() int {
c.ackMu.Lock()
defer c.ackMu.Unlock()
return c.acks
}
+453
View File
@@ -0,0 +1,453 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// Package realtime is the Alarm service's live delivery path: a WebSocket hub
// with one buffered send channel per client, an in-memory last-event cache for
// reconnect catch-up, and a PostgreSQL LISTEN/NOTIFY bridge so a trigger or
// disarm started on one service instance reaches subscribers connected to any
// other instance.
//
// Design rules (frozen contract v1):
//
// - The hub NEVER blocks: Broadcast does a non-blocking channel send and
// drops a client whose buffer is full (slow client), rather than stalling
// the other subscribers.
// - All deadlines live in this layer, not in http.Server: the service sets
// Read/WriteTimeout=0 because a non-zero server timeout kills a hijacked
// WebSocket connection. Each connection gets its own read deadline
// (PongWait), per-message write deadline (WriteWait) and server pings
// (PingInterval).
// - De-duplication is by event id: the in-process fast path and the NOTIFY
// bridge both deliver the same event, and the second one is dropped.
package realtime
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"sync"
"time"
"git.dcntrlized.org/Dcntrlized/Alarm/internal/alertstate"
"github.com/google/uuid"
"github.com/gorilla/websocket"
)
// Defaults for the keepalive envelope.
const (
DefaultPingInterval = 20 * time.Second
DefaultPongWait = 60 * time.Second
DefaultWriteWait = 10 * time.Second
DefaultSendBuffer = 16
DefaultMaxFrameSize = 4096
// ackCap bounds the per-event ack counters kept for observability.
ackCap = 256
)
// AlarmFrame is the server -> client alarm frame.
type AlarmFrame struct {
Type string `json:"type"` // always "alarm"
AlertID string `json:"alertId"`
EventID string `json:"eventId"`
Action string `json:"action"` // "trigger" | "disarm"
Message string `json:"message"`
ServerTime string `json:"serverTime"` // RFC3339, UTC
AutoStopSeconds int `json:"autoStopSeconds"`
}
// AckFrame is the server -> client acknowledgement of a client ack.
type AckFrame struct {
Type string `json:"type"` // always "ack"
EventID string `json:"eventId"`
}
// ClientFrame is the client -> server frame. `ping` is a keepalive; the server
// answers it with a WebSocket pong control frame (the contract defines no JSON
// pong) and refreshes subscriptions.last_seen_at.
type ClientFrame struct {
Type string `json:"type"`
EventID string `json:"eventId,omitempty"`
}
// Identity is the authenticated WebSocket caller.
type Identity struct {
AlertID uuid.UUID
UserID uuid.UUID
IsAdmin bool
SubscriptionID *uuid.UUID
DeviceID string
}
// AuthError lets the authentication callback choose the pre-upgrade HTTP
// status and wire error code. Any other error is reported as 401
// ERR_UNAUTHORIZED.
type AuthError struct {
Status int
Code string
Message string
}
func (e *AuthError) Error() string {
return fmt.Sprintf("realtime: auth rejected (%d %s): %s", e.Status, e.Code, e.Message)
}
// Options configures a Hub. Authenticate is required; the rest default.
type Options struct {
// Authenticate resolves the caller from the upgrade request (query params
// plus headers). Returning an error refuses the upgrade.
Authenticate func(r *http.Request) (*Identity, error)
// Touch refreshes subscriptions.last_seen_at. Optional.
Touch func(ctx context.Context, subscriptionID uuid.UUID) error
PingInterval time.Duration
PongWait time.Duration
WriteWait time.Duration
SendBuffer int
MaxFrameBytes int64
Logf func(format string, v ...any)
}
func (o Options) withDefaults() Options {
if o.PingInterval <= 0 {
o.PingInterval = DefaultPingInterval
}
if o.PongWait <= 0 {
o.PongWait = DefaultPongWait
}
if o.WriteWait <= 0 {
o.WriteWait = DefaultWriteWait
}
if o.SendBuffer <= 0 {
o.SendBuffer = DefaultSendBuffer
}
if o.MaxFrameBytes <= 0 {
o.MaxFrameBytes = DefaultMaxFrameSize
}
if o.Logf == nil {
o.Logf = log.Printf
}
return o
}
type lastEvent struct {
frame AlarmFrame
at time.Time
}
func (e lastEvent) active(now time.Time) bool {
if e.frame.Action != alertstate.KindTrigger {
return false
}
if e.frame.AutoStopSeconds <= 0 {
return true
}
return now.Before(e.at.Add(time.Duration(e.frame.AutoStopSeconds) * time.Second))
}
// Hub fans alarm frames out to every WebSocket client of an alert.
type Hub struct {
opts Options
upgrader websocket.Upgrader
mu sync.Mutex
clients map[uuid.UUID]map[*client]struct{}
last map[uuid.UUID]lastEvent
acks map[string]int
ackRing []string
closed bool
wg sync.WaitGroup
}
// NewHub builds a hub. It panics when Authenticate is nil: a hub that cannot
// authenticate must never be mounted.
func NewHub(opts Options) *Hub {
if opts.Authenticate == nil {
panic("realtime: Options.Authenticate is required")
}
opts = opts.withDefaults()
return &Hub{
opts: opts,
upgrader: websocket.Upgrader{
HandshakeTimeout: 10 * time.Second,
ReadBufferSize: 1024,
WriteBufferSize: 1024,
// Mobile clients send no Origin and the handshake is bearer-token
// authenticated (X-Access-Token + alert token), so Origin carries
// no authority here. A browser page cannot read the token.
CheckOrigin: func(r *http.Request) bool { return true },
},
clients: make(map[uuid.UUID]map[*client]struct{}),
last: make(map[uuid.UUID]lastEvent),
acks: make(map[string]int),
}
}
// Handler upgrades GET /ws/alarm?alertId=&token= and serves the connection.
func (h *Hub) Handler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.Header().Set("Allow", http.MethodGet)
writeJSONError(w, http.StatusMethodNotAllowed, "ERR_METHOD_NOT_ALLOWED", "method not allowed")
return
}
identity, err := h.opts.Authenticate(r)
if err != nil {
var authErr *AuthError
if errors.As(err, &authErr) {
writeJSONError(w, authErr.Status, authErr.Code, authErr.Message)
return
}
writeJSONError(w, http.StatusUnauthorized, "ERR_UNAUTHORIZED", "unauthorized")
return
}
h.mu.Lock()
closed := h.closed
h.mu.Unlock()
if closed {
writeJSONError(w, http.StatusServiceUnavailable, "ERR_UNAVAILABLE", "server is shutting down")
return
}
conn, err := h.upgrader.Upgrade(w, r, nil)
if err != nil {
// Upgrade already wrote the HTTP error response.
h.opts.Logf("ws: upgrade failed alert=%s: %v", identity.AlertID, err)
return
}
c := newClient(h, conn, *identity)
h.register(c)
// Reconnect catch-up: replay the last event only while it is still
// live, so a client that reconnects mid-alarm fires immediately and a
// client reconnecting after the alarm is never re-alarmed.
if f, ok := h.lastActive(identity.AlertID); ok {
if b, err := json.Marshal(f); err == nil {
c.trySend(b)
}
}
c.touch()
h.wg.Add(2)
go func() {
defer h.wg.Done()
c.writePump()
}()
go func() {
defer h.wg.Done()
c.readPump()
}()
}
}
func (h *Hub) register(c *client) {
h.mu.Lock()
defer h.mu.Unlock()
if h.clients[c.identity.AlertID] == nil {
h.clients[c.identity.AlertID] = make(map[*client]struct{})
}
h.clients[c.identity.AlertID][c] = struct{}{}
}
func (h *Hub) unregister(c *client) {
h.mu.Lock()
defer h.mu.Unlock()
if set, ok := h.clients[c.identity.AlertID]; ok {
delete(set, c)
if len(set) == 0 {
delete(h.clients, c.identity.AlertID)
}
}
}
// Broadcast delivers f to every live client of the alert and returns how many
// connections the alert has on this instance. A duplicate event id (the same
// event arriving through both the in-process fast path and the NOTIFY bridge)
// is delivered once.
func (h *Hub) Broadcast(alertID uuid.UUID, f AlarmFrame) int {
if f.Type == "" {
f.Type = "alarm"
}
h.mu.Lock()
if h.closed {
h.mu.Unlock()
return 0
}
if prev, ok := h.last[alertID]; ok && prev.frame.EventID != "" && prev.frame.EventID == f.EventID {
n := len(h.clients[alertID])
h.mu.Unlock()
return n
}
at := time.Now().UTC()
if ts, err := time.Parse(time.RFC3339Nano, f.ServerTime); err == nil {
at = ts
}
h.last[alertID] = lastEvent{frame: f, at: at}
targets := make([]*client, 0, len(h.clients[alertID]))
for c := range h.clients[alertID] {
targets = append(targets, c)
}
h.mu.Unlock()
payload, err := json.Marshal(f)
if err != nil {
h.opts.Logf("ws: marshal frame alert=%s event=%s: %v", alertID, f.EventID, err)
return 0
}
delivered := 0
for _, c := range targets {
if c.trySend(payload) {
delivered++
continue
}
// Buffer full: the client is not keeping up. Dropping it keeps the
// alarm live for everyone else.
h.opts.Logf("ws: dropping slow client user=%s alert=%s", c.identity.UserID, alertID)
c.close()
}
return delivered
}
// LastEvent returns the most recent broadcast frame for an alert, active or
// not. Exposed for alarmStatus cross-checks and tests.
func (h *Hub) LastEvent(alertID uuid.UUID) (AlarmFrame, bool) {
h.mu.Lock()
defer h.mu.Unlock()
e, ok := h.last[alertID]
return e.frame, ok
}
func (h *Hub) lastActive(alertID uuid.UUID) (AlarmFrame, bool) {
h.mu.Lock()
defer h.mu.Unlock()
e, ok := h.last[alertID]
if !ok || !e.active(time.Now().UTC()) {
return AlarmFrame{}, false
}
return e.frame, true
}
// ClientCount returns the number of live connections for an alert.
func (h *Hub) ClientCount(alertID uuid.UUID) int {
h.mu.Lock()
defer h.mu.Unlock()
return len(h.clients[alertID])
}
// TotalClients returns the number of live connections across all alerts.
func (h *Hub) TotalClients() int {
h.mu.Lock()
defer h.mu.Unlock()
n := 0
for _, set := range h.clients {
n += len(set)
}
return n
}
func (h *Hub) recordAck(eventID string) {
if eventID == "" {
return
}
h.mu.Lock()
defer h.mu.Unlock()
if _, seen := h.acks[eventID]; !seen {
h.ackRing = append(h.ackRing, eventID)
for len(h.ackRing) > ackCap {
evict := h.ackRing[0]
h.ackRing = h.ackRing[1:]
delete(h.acks, evict)
}
}
h.acks[eventID]++
}
// Acks returns how many clients acknowledged an event on this instance.
func (h *Hub) Acks(eventID string) int {
h.mu.Lock()
defer h.mu.Unlock()
return h.acks[eventID]
}
// Run blocks until ctx is cancelled, then drains every connection. It is meant
// to be started as `go hub.Run(ctx)`.
func (h *Hub) Run(ctx context.Context) {
<-ctx.Done()
drainCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := h.Shutdown(drainCtx); err != nil {
h.opts.Logf("ws: drain incomplete: %v", err)
}
}
// Shutdown stops accepting connections, asks every client to close, and waits
// for the pumps to finish or ctx to expire. It is idempotent.
func (h *Hub) Shutdown(ctx context.Context) error {
h.mu.Lock()
h.closed = true
all := make([]*client, 0, 16)
for _, set := range h.clients {
for c := range set {
all = append(all, c)
}
}
h.mu.Unlock()
for _, c := range all {
c.close()
}
done := make(chan struct{})
go func() {
h.wg.Wait()
close(done)
}()
select {
case <-done:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func (h *Hub) touch(c *client) {
if h.opts.Touch == nil || c.identity.SubscriptionID == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := h.opts.Touch(ctx, *c.identity.SubscriptionID); err != nil {
h.opts.Logf("ws: last_seen_at update failed sub=%s: %v", *c.identity.SubscriptionID, err)
}
}
func writeJSONError(w http.ResponseWriter, status int, code, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]any{
"error": map[string]string{"code": code, "message": message},
})
}
+238
View File
@@ -0,0 +1,238 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package realtime
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/gorilla/websocket"
)
// testIdentity is what a successful authentication resolves to.
func testIdentity(alertID uuid.UUID) Identity {
return Identity{AlertID: alertID, UserID: uuid.New()}
}
// startTestServer runs a hub whose authentication always succeeds and returns
// the server, its ws:// URL and the hub.
func startTestServer(t *testing.T, alertID uuid.UUID) (*httptest.Server, string, *Hub) {
t.Helper()
hub := NewHub(Options{
Authenticate: func(r *http.Request) (*Identity, error) {
id := testIdentity(alertID)
return &id, nil
},
Logf: func(format string, v ...any) { t.Logf(format, v...) },
})
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
go hub.Run(ctx)
srv := httptest.NewServer(hub.Handler())
t.Cleanup(srv.Close)
return srv, "ws" + strings.TrimPrefix(srv.URL, "http"), hub
}
// dial opens one subscriber connection.
func dial(t *testing.T, wsURL string) *websocket.Conn {
t.Helper()
conn, resp, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
status := 0
if resp != nil {
status = resp.StatusCode
}
t.Fatalf("dial %s: %v (http %d)", wsURL, err, status)
}
t.Cleanup(func() { _ = conn.Close() })
return conn
}
// waitClients blocks until the hub has n clients on the alert.
func waitClients(t *testing.T, hub *Hub, alertID uuid.UUID, n int) {
t.Helper()
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if hub.ClientCount(alertID) == n {
return
}
time.Sleep(2 * time.Millisecond)
}
t.Fatalf("hub has %d clients on alert %s, want %d", hub.ClientCount(alertID), alertID, n)
}
// readAlarm reads one server frame and requires it to be an alarm frame.
func readAlarm(t *testing.T, conn *websocket.Conn) AlarmFrame {
t.Helper()
if err := conn.SetReadDeadline(time.Now().Add(3 * time.Second)); err != nil {
t.Fatalf("set read deadline: %v", err)
}
var frame AlarmFrame
if err := conn.ReadJSON(&frame); err != nil {
t.Fatalf("read alarm frame: %v", err)
}
if frame.Type != "alarm" {
t.Fatalf("frame type = %q, want \"alarm\"", frame.Type)
}
return frame
}
// TestBroadcastReachesTwoClientsAndAckRoundTrips is the task-4 acceptance test:
// ONE Broadcast call must reach TWO live subscribers, each must see the same
// event id, and each client ack must be recorded by the hub.
func TestBroadcastReachesTwoClientsAndAckRoundTrips(t *testing.T) {
alertID := uuid.New()
_, wsURL, hub := startTestServer(t, alertID)
first := dial(t, wsURL)
second := dial(t, wsURL)
waitClients(t, hub, alertID, 2)
eventID := uuid.NewString()
frame := AlarmFrame{
Type: "alarm",
AlertID: alertID.String(),
EventID: eventID,
Action: "trigger",
Message: "fire alarm",
ServerTime: time.Now().UTC().Format(time.RFC3339Nano),
}
if delivered := hub.Broadcast(alertID, frame); delivered != 2 {
t.Fatalf("Broadcast delivered to %d clients, want 2", delivered)
}
for i, conn := range []*websocket.Conn{first, second} {
got := readAlarm(t, conn)
if got.EventID != eventID {
t.Fatalf("client %d event id = %q, want %q", i, got.EventID, eventID)
}
if got.Action != "trigger" || got.Message != "fire alarm" {
t.Fatalf("client %d frame = %+v, want the triggered alarm", i, got)
}
// Ack round-trip: the client acknowledges, the hub counts it.
if err := conn.WriteJSON(ClientFrame{Type: "ack", EventID: eventID}); err != nil {
t.Fatalf("client %d ack: %v", i, err)
}
}
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) && hub.Acks(eventID) < 2 {
time.Sleep(2 * time.Millisecond)
}
if got := hub.Acks(eventID); got != 2 {
t.Fatalf("hub recorded %d acks for event %s, want 2", got, eventID)
}
}
// TestBroadcastIsDedupedByEventID proves the local broadcast and the database
// NOTIFY path cannot deliver the same event twice: a repeat broadcast of an
// event the hub already holds delivers nothing, it only reports how many
// clients currently hold it.
func TestBroadcastIsDedupedByEventID(t *testing.T) {
alertID := uuid.New()
_, wsURL, hub := startTestServer(t, alertID)
conn := dial(t, wsURL)
waitClients(t, hub, alertID, 1)
frame := AlarmFrame{
Type: "alarm",
AlertID: alertID.String(),
EventID: uuid.NewString(),
Action: "trigger",
ServerTime: time.Now().UTC().Format(time.RFC3339Nano),
}
if delivered := hub.Broadcast(alertID, frame); delivered != 1 {
t.Fatalf("first Broadcast delivered to %d clients, want 1", delivered)
}
// Same event id again: the frame must NOT be written a second time.
if delivered := hub.Broadcast(alertID, frame); delivered != 1 {
t.Fatalf("repeat Broadcast reported %d clients, want 1 (already held)", delivered)
}
if got := readAlarm(t, conn); got.EventID != frame.EventID {
t.Fatalf("event id = %q, want %q", got.EventID, frame.EventID)
}
// Nothing else may arrive: the duplicate was suppressed, not queued.
if err := conn.SetReadDeadline(time.Now().Add(250 * time.Millisecond)); err != nil {
t.Fatalf("set read deadline: %v", err)
}
var extra AlarmFrame
if err := conn.ReadJSON(&extra); err == nil {
t.Fatalf("duplicate event was delivered: %+v", extra)
}
}
// TestReconnectReplaysLiveTrigger covers the catch-up path: a subscriber that
// connects while an alarm is still live is alarmed immediately.
func TestReconnectReplaysLiveTrigger(t *testing.T) {
alertID := uuid.New()
_, wsURL, hub := startTestServer(t, alertID)
// Broadcast with nobody connected: the hub keeps it as the last event.
frame := AlarmFrame{
Type: "alarm",
AlertID: alertID.String(),
EventID: uuid.NewString(),
Action: "trigger",
Message: "still ringing",
ServerTime: time.Now().UTC().Format(time.RFC3339Nano),
AutoStopSeconds: 300,
}
if delivered := hub.Broadcast(alertID, frame); delivered != 0 {
t.Fatalf("Broadcast with no clients delivered to %d, want 0", delivered)
}
conn := dial(t, wsURL)
got := readAlarm(t, conn)
if got.EventID != frame.EventID || got.Message != "still ringing" {
t.Fatalf("catch-up frame = %+v, want event %s", got, frame.EventID)
}
}
// TestHandlerRejectsNonGet keeps the WS endpoint's error shape uniform with
// /action/*: a plain JSON error, not an upgrade attempt.
func TestHandlerRejectsNonGet(t *testing.T) {
alertID := uuid.New()
srv, _, _ := startTestServer(t, alertID)
resp, err := http.Post(srv.URL+"/ws/alarm", "application/json", strings.NewReader("{}"))
if err != nil {
t.Fatalf("post: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusMethodNotAllowed {
t.Fatalf("status = %d, want 405", resp.StatusCode)
}
var body struct {
Error struct {
Code string `json:"code"`
} `json:"error"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatalf("decode error body: %v", err)
}
if body.Error.Code == "" {
t.Fatalf("error body has no code: %+v", body)
}
}
+77
View File
@@ -0,0 +1,77 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package realtime
import (
"sort"
"testing"
"time"
"github.com/google/uuid"
"github.com/gorilla/websocket"
)
// TestBroadcastLatency measures the fan-out path on real sockets: from the
// instant Broadcast is called to the instant BOTH subscribers have decoded the
// frame. It excludes the HTTP action and the database insert that precede a
// trigger in production, so it is the floor for the end-to-end number.
//
// It logs p50/p95/max and only fails on a gross regression (p95 > 500ms) to
// stay useful without being flaky on a loaded machine.
func TestBroadcastLatency(t *testing.T) {
alertID := uuid.New()
_, wsURL, hub := startTestServer(t, alertID)
first := dial(t, wsURL)
second := dial(t, wsURL)
waitClients(t, hub, alertID, 2)
const iterations = 100
samples := make([]time.Duration, 0, iterations)
for i := 0; i < iterations; i++ {
eventID := uuid.NewString()
frame := AlarmFrame{
Type: "alarm",
AlertID: alertID.String(),
EventID: eventID,
Action: "trigger",
Message: "latency probe",
ServerTime: time.Now().UTC().Format(time.RFC3339Nano),
AutoStopSeconds: 300,
}
start := time.Now()
if delivered := hub.Broadcast(alertID, frame); delivered != 2 {
t.Fatalf("iteration %d: Broadcast delivered to %d clients, want 2", i, delivered)
}
for _, conn := range []*websocket.Conn{first, second} {
got := readAlarm(t, conn)
if got.EventID != eventID {
t.Fatalf("iteration %d: event id = %q, want %q", i, got.EventID, eventID)
}
}
samples = append(samples, time.Since(start))
}
sort.Slice(samples, func(i, j int) bool { return samples[i] < samples[j] })
p50 := samples[len(samples)*50/100]
p95 := samples[len(samples)*95/100]
max := samples[len(samples)-1]
t.Logf("broadcast -> 2 clients decoded: n=%d p50=%v p95=%v max=%v",
len(samples), p50, p95, max)
if p95 > 500*time.Millisecond {
t.Fatalf("p95 latency %v exceeds the 500ms budget", p95)
}
}
+141
View File
@@ -0,0 +1,141 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package realtime
import (
"context"
"encoding/json"
"errors"
"time"
"git.dcntrlized.org/Dcntrlized/Alarm/internal/alertstate"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// NotifyChannel is the PostgreSQL NOTIFY channel the alarm schema publishes
// trigger/disarm events on (see alarm.notify_alarm_event() in
// database/migrations/001_alarm_schema.sql).
const NotifyChannel = "alarm_events"
// NotifyPayload is the transport form of an alarm frame on the NOTIFY channel.
// It is emitted by the database trigger, so every service instance — not just
// the one that served the trigger request — fans the event out to its own
// subscribers.
type NotifyPayload struct {
AlertID string `json:"alertId"`
EventID string `json:"eventId"`
Action string `json:"action"`
Message string `json:"message"`
AutoStopSeconds int `json:"autoStopSeconds"`
CreatedAtMs int64 `json:"createdAtMs"`
}
// Frame converts the transport payload into the wire frame. ServerTime is the
// EVENT time, not the delivery time, so every instance and every client agrees
// on when the alarm started (and therefore on the auto-stop countdown).
func (p NotifyPayload) Frame() AlarmFrame {
return AlarmFrame{
Type: "alarm",
AlertID: p.AlertID,
EventID: p.EventID,
Action: p.Action,
Message: p.Message,
ServerTime: time.UnixMilli(p.CreatedAtMs).UTC().Format(time.RFC3339Nano),
AutoStopSeconds: p.AutoStopSeconds,
}
}
// ErrBadNotifyPayload reports a NOTIFY payload that cannot be trusted; the
// bridge logs and ignores it rather than delivering a malformed frame.
var ErrBadNotifyPayload = errors.New("realtime: malformed notify payload")
// DeliverNotifyPayload parses one NOTIFY payload and broadcasts the frame. It
// is the unit-testable half of the bridge (no PostgreSQL needed).
func (h *Hub) DeliverNotifyPayload(payload []byte) (AlarmFrame, bool, error) {
var p NotifyPayload
if err := json.Unmarshal(payload, &p); err != nil {
return AlarmFrame{}, false, ErrBadNotifyPayload
}
alertID, err := uuid.Parse(p.AlertID)
if err != nil {
return AlarmFrame{}, false, ErrBadNotifyPayload
}
if _, err := uuid.Parse(p.EventID); err != nil {
return AlarmFrame{}, false, ErrBadNotifyPayload
}
if !alertstate.ValidKind(p.Action) {
return AlarmFrame{}, false, ErrBadNotifyPayload
}
f := p.Frame()
h.Broadcast(alertID, f)
return f, true, nil
}
// RunNotifyBridge listens on the alarm_events channel and fans every payload
// out to the hub, reconnecting with exponential backoff. It returns nil when
// ctx is cancelled. Run it as `go realtime.RunNotifyBridge(ctx, hub, dsn)`.
func RunNotifyBridge(ctx context.Context, hub *Hub, dsn string) error {
backoff := 500 * time.Millisecond
const maxBackoff = 10 * time.Second
for {
if ctx.Err() != nil {
return nil
}
err := listenOnce(ctx, hub, dsn)
if ctx.Err() != nil {
return nil
}
hub.opts.Logf("notify: bridge disconnected (%v); reconnecting in %s", err, backoff)
select {
case <-ctx.Done():
return nil
case <-time.After(backoff):
}
if backoff < maxBackoff {
backoff *= 2
if backoff > maxBackoff {
backoff = maxBackoff
}
}
}
}
func listenOnce(ctx context.Context, hub *Hub, dsn string) error {
conn, err := pgx.Connect(ctx, dsn)
if err != nil {
return err
}
defer func() { _ = conn.Close(context.Background()) }()
if _, err := conn.Exec(ctx, "LISTEN "+NotifyChannel); err != nil {
return err
}
hub.opts.Logf("notify: listening on channel %s", NotifyChannel)
for {
n, err := conn.WaitForNotification(ctx)
if err != nil {
return err
}
if _, ok, err := hub.DeliverNotifyPayload([]byte(n.Payload)); err != nil {
hub.opts.Logf("notify: ignored payload: %v", err)
} else if ok {
hub.opts.Logf("notify: delivered event from channel %s", NotifyChannel)
}
}
}
+96
View File
@@ -0,0 +1,96 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package realtime
import (
"context"
"net/http"
"sync"
"git.dcntrlized.org/Dcntrlized/Alarm/db"
"git.dcntrlized.org/Dcntrlized/Alarm/logger"
)
// current is the hub built by Register, read by the action layer for the
// in-process broadcast fast path. It is written once during startup, before the
// listener accepts traffic, and never mutated afterwards.
var current *Hub
// Current returns the process-wide hub, or nil when the realtime layer was
// never registered (unit tests, or a deliberately API-only process).
func Current() *Hub { return current }
// Register installs GET /ws/alarm on mux, starts the hub and the database
// notify bridge, and returns a drain function to be called once during
// shutdown. It does not block: the hub goroutine runs until the returned drain
// function is called or its context expires.
//
// The route is registered on the OUTER mux, never behind the API chain: the
// upgrade is hijacked, and the subscribe token rides the query string, so the
// request must not pass through the logging middleware.
//
// `database` is the service's single pool handle. The hub only needs the DSN
// for the LISTEN bridge, but accepting the handle keeps one pool per process
// and makes the dependency explicit.
func Register(mux *http.ServeMux, database *db.DB) func(ctx context.Context) {
hub := NewHub(Options{
Authenticate: authenticateWS,
Touch: db.TouchSubscription,
Logf: func(format string, v ...any) {
logger.Warn(format, v...)
},
})
current = hub
// The hub's lifetime is owned here, not by the caller: the drain function
// cancels it, which makes Run stop accepting and drain its clients.
hubCtx, cancelHub := context.WithCancel(context.Background())
go hub.Run(hubCtx)
dsn := db.DatabaseURL()
if database != nil && database.Pool != nil {
// Prefer the pool's own config so an injected DSN is honoured.
dsn = database.Pool.Config().ConnString()
}
hub.StartNotifyBridge(hubCtx, dsn)
mux.Handle("/ws/alarm", hub.Handler())
var once sync.Once
return func(ctx context.Context) {
once.Do(func() {
cancelHub()
if err := hub.Shutdown(ctx); err != nil && ctx.Err() == nil {
logger.Warn("realtime: hub drain incomplete: %v", err)
}
})
}
}
// StartNotifyBridge subscribes this instance to the database's alarm_events
// channel so a trigger raised on ANY instance reaches this one's subscribers.
// It returns immediately; the listener reconnects with backoff until ctx ends.
func (h *Hub) StartNotifyBridge(ctx context.Context, dsn string) {
if dsn == "" {
logger.Warn("realtime: no database url, cross-instance notify bridge disabled")
return
}
go func() {
if err := RunNotifyBridge(ctx, h, dsn); err != nil && ctx.Err() == nil {
logger.Error("realtime: notify bridge stopped: %v", err)
}
}()
}
+54
View File
@@ -0,0 +1,54 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package types
import ()
type Manifest struct {
Name string `json:"name"`
Version *string `json:"version,omitempty"`
Author string `json:"author"`
Capabilities []string `json:"capabilities"`
HasAuth bool `json:"has_auth"`
PublicKey string `json:"public_key"`
Icon *string `json:"icon,omitempty"`
}
type UserCredentials struct {
Username string `json:"username"`
Password string `json:"password"`
}
type UserRegistration struct {
Username string `json:"username"`
Password string `json:"password"`
Email string `json:"email"`
}
type AuthResponse struct {
AccessToken *string `json:"accessToken,omitempty"`
RefreshToken *string `json:"refreshToken,omitempty"`
User interface{} `json:"user,omitempty"`
}
type ActionRequest struct {
Payload interface{} `json:"payload,omitempty"`
}
type ActionResponse struct {
Status *string `json:"status,omitempty"`
Data interface{} `json:"data,omitempty"`
}