docs: the ack token is scoped, not single-use

The handler never deletes the token: it stays valid until expires_at and
is purged by the sweeper, so a second tap is an idempotent no-op rather
than a rejection. Caught by pressing Acknowledge twice against the live
server. What bounds the token is scope -- one incident, one action, one
day -- not a use count.
This commit is contained in:
Niklas Ye
2026-08-07 11:41:15 +02:00
parent 7caafbaf80
commit 17ee290d90
3 changed files with 14 additions and 7 deletions
+12 -5
View File
@@ -211,10 +211,17 @@ Three things get pushed:
Notifications carry an **Acknowledge** button that acknowledges the incident Notifications carry an **Acknowledge** button that acknowledges the incident
without opening anything. It POSTs to `/api/notify/ack/{token}`, an without opening anything. It POSTs to `/api/notify/ack/{token}`, an
unauthenticated route authorised by the 256-bit single-use token in its path — unauthenticated route authorised by the 256-bit token in its path — minted fresh
minted fresh per notification, scoped to one incident and one action, and valid per notification, scoped to one incident and one action, and valid for 24 hours.
for 24 hours. A real API key is never put in a notification, because the message A real API key is never put in a notification, because the message is stored on
is stored on the ntfy server and cached on the device. the ntfy server and cached on the device.
The token is **not** consumed by use. Acknowledging is idempotent, so a token
stays valid for its full 24 hours and a second tap is a no-op that reports the
incident's current state rather than an error — which is what you want when a
tap is retried on a flaky mobile connection. What bounds it is scope, not a use
count: one incident, one action, one day. Expired tokens are purged by the
sweeper.
Two consequences worth planning for: Two consequences worth planning for:
@@ -281,7 +288,7 @@ Authorization: Bearer <api-key>
| Method | Path | Description | | Method | Path | Description |
|---|---|---| |---|---|---|
| `POST` | `/api/notify/ack/{token}` | Acknowledge an incident from a push notification's Acknowledge button. No auth: the single-use token in the path is the credential. Must stay publicly reachable | | `POST` | `/api/notify/ack/{token}` | Acknowledge an incident from a push notification's Acknowledge button. No auth: the token in the path is the credential — one incident, one action, 24 hours, idempotent. Must stay publicly reachable |
### Incidents ### Incidents
+1 -1
View File
@@ -42,7 +42,7 @@ notify:
# #
# The Acknowledge button is a POST to /api/notify/ack/{token} from the # The Acknowledge button is a POST to /api/notify/ack/{token} from the
# responder's phone, so that path has to stay publicly reachable — it is # responder's phone, so that path has to stay publicly reachable — it is
# authorised by the single-use token in the URL, not by network placement. # authorised by the scoped token in the URL, not by network placement.
publicUrl: "" publicUrl: ""
# Optional bearer token for an access-controlled ntfy, read from an existing # Optional bearer token for an access-controlled ntfy, read from an existing
# Secret. Leave name empty for an open ntfy. # Secret. Leave name empty for an open ntfy.
+1 -1
View File
@@ -22,7 +22,7 @@ func NewRouter(db *sql.DB, notify NotifyConfig) http.Handler {
// Unauthenticated: bootstrap, the Alertmanager webhook receiver, and the // Unauthenticated: bootstrap, the Alertmanager webhook receiver, and the
// Acknowledge button in a push notification. The last one is authorised by // Acknowledge button in a push notification. The last one is authorised by
// the single-use token in its path rather than an API key, and has to stay // the scoped token in its path rather than an API key, and has to stay
// reachable from outside the cluster for the button to work. // reachable from outside the cluster for the button to work.
r.Post("/api/bootstrap", handleBootstrap(db)) r.Post("/api/bootstrap", handleBootstrap(db))
r.Post("/api/alertmanager/webhook", handleAlertmanagerWebhook(db, notify)) r.Post("/api/alertmanager/webhook", handleAlertmanagerWebhook(db, notify))