fix(usage): close the QA findings on opt-in product usage

A QA exploration of this branch against an isolated rig — own stub
collector, SQLite and PostgreSQL — turned up one dead end and a set of
signals and controls that did not hold up. This closes all of them.

Rotating JWT_SECRET, the documented response to a suspected compromise,
made the signing key unreadable. That was already named and documented,
but it left no way out: the delete packet can never be signed, so the
row stays deletion_pending forever, and enable() refuses because it is
not `disabled`. An operator who rotated precisely because the secret was
compromised cannot restore it, so the feature was bricked with no
control left. POST /usage/abandon is offered only in that state; it
drops the local identity and records the receipt as
`collector-unconfirmed` rather than claiming a deletion that did not
happen.

Every failed delivery was retried on the next admin request, and
/activity is open to any authenticated admin while the settings ticker
fires it every five minutes per open tab — 30 activity calls against a
rejecting collector produced 30 outbound requests. Migration 206 adds
attempts/next_attempt_at and the unattended sender honours the gate;
Retry and opt-out still send immediately, and the tab names the time of
the next automatic attempt.

Feedback, votes and portal sessions now share an installation-wide
budget of 30/hour. They are the only endpoints whose effect is outbound
traffic carrying operator-written free text, and the general limiter
skips authenticated requests by design. Reading status and withdrawing
stay unthrottled.

gallery_image_protection was true on a bare install with no galleries:
PicPeak ships default_protection_level='standard' and
enable_devtools_protection=true, so it reported fleet-wide 100% and
could never separate a decision from an untouched default. It now reads
only what deviates from the shipped defaults, and the devtools flag is
not read at all — being on by default, its only informative state is
off, which is the opposite of what the key claims.

Also:
- the export receipt counted every packet and called the total "usage
  reports"; reports and participant operations are now counted and named
  separately
- GET /usage/preview no longer persists the custom_css marker, so the
  transparency view stops changing what will be sent
- the feedback route requires every field the packet schema requires,
  so an API caller gets the missing field named instead of a bare
  INVALID_PACKET from inside signing
- the German strings for this feature use "Sie" throughout, matching the
  rest of the admin UI; the ignore hint says what ignoring will do
  rather than stating it as already true
- the consent dialog returns focus to the control that opened it
- the long buttons wrap instead of running off a 390px viewport
- a deletion receipt is labelled as belonging to an earlier
  participation while a new one is active

Regression tests cover each of these, including the delete packet's
reuse of the last accepted sequence, which was an unwritten assumption
about the collector rather than a defect.
This commit is contained in:
Paul Nothaft
2026-09-06 17:40:43 +02:00
parent a7382591bf
commit 1e8b6f1b0f
20 changed files with 1076 additions and 62 deletions
+33 -4
View File
@@ -45,7 +45,15 @@ defaults to `JWT_SECRET`, so rotating `JWT_SECRET` without setting a dedicated
`USAGE_ENCRYPTION_KEY` first loses it. The settings page then reports
`SIGNING_KEY_UNREADABLE` rather than a generic delivery failure, because the
consequence is specific: reports stop and the deletion request can no longer
be signed either. Keys live in a dedicated database
be signed either. Restoring the original key material is the correct fix and
completes the pending deletion. When it is genuinely gone — a rotation done
because the secret was compromised — the settings page offers **Discard local
identity** (`POST /api/admin/usage/abandon`), which is available in no other
state. It erases the local identity, key material and markers and records an
abandonment receipt marked `collector-unconfirmed`: the collector was never
told, so it keeps the reports already accepted, and the receipt says so rather
than claiming a deletion that did not happen. Participation can be started
again afterwards with a fresh identity. Keys live in a dedicated database
table, not the generic readable settings. A random mode-0600 file at
`getStoragePath()/usage-instance.key` binds the database to its local storage.
@@ -75,6 +83,16 @@ durable and retried. Multiple admin tabs/processes share a database lease;
only accepted receipts advance the sequence and report date. Re-signed retries
reuse the immutable packet ID so lost acknowledgements do not duplicate data.
Retries are paced (migration 206). Consecutive failures set `attempts` and
`next_attempt_at`, and the unattended sender — the activity endpoint and the
settings ticker — waits for that gate: 2, 4, 8, 16, 32 minutes, then hourly.
Without it a packet the collector rejects permanently produced one collector
request per admin action, because any authenticated admin reaches the activity
endpoint and every open admin tab fires it every five minutes. Explicit
operator actions are not paced: **Retry** and opt-out send immediately, and the
settings page names the time of the next automatic attempt so a waiting
installation does not read as a broken one.
Opt-out immediately stops collection, clears markers/previews/feedback
preferences, and enters deletion pending. It keeps only credentials and the
deletion operation until the collector confirms deletion. The collector removes
@@ -84,9 +102,12 @@ a fresh identity. Repeated deletion handles lost receipts safely.
Migration 204 adds bounded, local-only privacy receipts and removes any legacy
plaintext voting token from the last collector receipt. A completed export
records its time and report count; confirmed opt-out replaces this with a
deletion receipt containing only a random receipt ID, time, status and fixed
scope. It retains no old installation hash, key, payload or credential. The
records its time, the number of accepted reports and the total number of
accepted packets separately — feedback, votes and portal sessions are
participant operations, not reports, and a receipt that folded them into one
"reports" figure stated something untrue about its own contents. Confirmed
opt-out replaces this with a deletion receipt containing only a random receipt
ID, time, status and fixed scope. It retains no old installation hash, key, payload or credential. The
settings page can download these receipts even after opt-out. They are local
records of the collector acknowledgement, not independent proof of storage
erasure. Downloaded exports carry their own dated receipt; the collector does
@@ -105,6 +126,14 @@ feedback preferences. Any authenticated admin may trigger the fixed daily
report; the activity endpoint accepts no telemetry input. Every usage endpoint
uses adminAuth, including token-type checks. Gallery tokens cannot use it.
Feedback, votes and portal sessions share one installation-wide budget of 30
per hour. They are the only endpoints whose effect is an outbound request
carrying operator-written free text, and the platform's general limiter skips
authenticated requests by design — correct for endpoints that touch only this
installation, wrong for a relay. Reading status, retrying and opting out are
never throttled: those are how an operator sees what is happening and how they
leave.
Feedback is sent only on explicit submission. Each item defaults anonymous and
private; names, publication permission, and testimonial marketing permission
are separate choices. Published requests/testimonials require maintainer review.