From 17ee290d90f2fd4b33d726e071f4cde9302ce756 Mon Sep 17 00:00:00 2001 From: Niklas Ye Date: Fri, 7 Aug 2026 11:41:15 +0200 Subject: [PATCH] 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. --- README.md | 17 ++++++++++++----- charts/terdut-server/values.yaml | 2 +- internal/api/router.go | 2 +- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index b3e2095..6b5eb5b 100644 --- a/README.md +++ b/README.md @@ -211,10 +211,17 @@ Three things get pushed: Notifications carry an **Acknowledge** button that acknowledges the incident without opening anything. It POSTs to `/api/notify/ack/{token}`, an -unauthenticated route authorised by the 256-bit single-use token in its path — -minted fresh per notification, scoped to one incident and one action, and valid -for 24 hours. A real API key is never put in a notification, because the message -is stored on the ntfy server and cached on the device. +unauthenticated route authorised by the 256-bit token in its path — minted fresh +per notification, scoped to one incident and one action, and valid for 24 hours. +A real API key is never put in a notification, because the message is stored on +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: @@ -281,7 +288,7 @@ Authorization: Bearer | 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 diff --git a/charts/terdut-server/values.yaml b/charts/terdut-server/values.yaml index de4817f..18e6e1d 100644 --- a/charts/terdut-server/values.yaml +++ b/charts/terdut-server/values.yaml @@ -42,7 +42,7 @@ notify: # # 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 - # 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: "" # Optional bearer token for an access-controlled ntfy, read from an existing # Secret. Leave name empty for an open ntfy. diff --git a/internal/api/router.go b/internal/api/router.go index d2783ff..fcb4fd1 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -22,7 +22,7 @@ func NewRouter(db *sql.DB, notify NotifyConfig) http.Handler { // Unauthenticated: bootstrap, the Alertmanager webhook receiver, and the // 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. r.Post("/api/bootstrap", handleBootstrap(db)) r.Post("/api/alertmanager/webhook", handleAlertmanagerWebhook(db, notify))