fix(usage): protect a pending withdrawal, widen the backup signal, explain an unreadable key

Three of four findings from the follow-up review.

A withdrawal is no longer clobbered by the instance-copy check. That
update was unconditional, so an opt-out arriving while the binding
lookup was in flight was replaced by identity_conflict — and tick()
stops there, so the deletion the operator asked for was never sent. It
now carries the same whereNot('deletion_pending') guard the
collector-conflict handler beside it already had.

Scheduled database backups count as a configured backup. The middleware
records /backup/* and /database-backup/* under one capability, but
`configured` read only backup_enabled, so an install whose only backup
is the scheduled database one reported used: true, configured: false —
a contradiction in the dataset this feature exists to produce.

SIGNING_KEY_UNREADABLE gets its own message. Naming the error in the
previous commit was half the job: the settings page still showed the
generic retry/disable advice, and neither action can succeed without the
original encryption material. It now says what happened and what is
actually required, in EN and DE.

NOT fixed, and reported instead: /disable overlapping an in-flight
/enable. While activation is still doing its slow work the row still
reads `disabled`, so disable's conditional update matches nothing and
the lease conflict from its tick() is swallowed — the operator is told
participation is off while activation completes and leaves it on.
Closing it properly needs a persisted cancellation flag that enable
checks before finalising: taking the lease cannot help, since it either
conflicts immediately or would block the request for the 60s lease. That
is a schema and state-machine decision for the author, not something to
restructure underneath them.

Refs #1110
This commit is contained in:
Paul Nothaft
2026-09-05 21:34:25 +02:00
parent c043897b0e
commit 83fbb63e13
4 changed files with 26 additions and 2 deletions
+13 -1
View File
@@ -48,6 +48,7 @@ const SETTING_KEYS = [
'oidc_issuer_url',
'oidc_client_id',
'backup_enabled',
'database_backup_enabled',
'backup_destination_type',
'backup_s3_bucket',
'theme_config',
@@ -308,6 +309,12 @@ class UsageService {
) {
await this.db('product_usage_state')
.where({ id: 1 })
// Never over an opt-out. A withdrawal that arrived while this
// binding lookup was in flight would otherwise be replaced by
// identity_conflict, and tick() stops there — so the deletion the
// operator asked for would never be sent. The collector-conflict
// handler below already guards the same way.
.whereNot({ status: 'deletion_pending' })
.update({
status: 'identity_conflict',
last_error: 'INSTANCE_COPY_DETECTED'
@@ -507,7 +514,12 @@ class UsageService {
features.oauth.configured =
truth(settings.oidc_enabled) &&
Boolean(settings.oidc_issuer_url && settings.oidc_client_id);
features.backup.configured = truth(settings.backup_enabled);
// Either kind counts. The middleware records /backup/* and
// /database-backup/* under the same capability, so reading only
// backup_enabled reported `used: true, configured: false` for an install
// whose only backup is the scheduled database one.
features.backup.configured =
truth(settings.backup_enabled) || truth(settings.database_backup_enabled);
features.s3_storage.configured =
(settings.backup_destination_type === 's3' &&
Boolean(settings.backup_s3_bucket)) ||
@@ -156,7 +156,17 @@ export default function ProductUsageTab() {
<p>{t('productUsage.lastReport', { date: data.last_report_date })}</p>
)}
{data.last_error && (
<p role="status">{t('productUsage.deliveryProblem')}</p>
<p role="status">
{/* Retrying cannot fix an unreadable signing key, and neither can
disabling: without the original encryption material the
deletion request cannot be signed either. Telling the operator
to retry would send them in a circle. */}
{t(
data.last_error === 'SIGNING_KEY_UNREADABLE'
? 'productUsage.signingKeyUnreadable'
: 'productUsage.deliveryProblem'
)}
</p>
)}
<div className="flex flex-wrap gap-3">
{data.status === 'disabled' ? (
+1
View File
@@ -24,6 +24,7 @@
"hash": "Dein vertraulicher Abfrage-Hash",
"lastReport": "Zuletzt angenommener Bericht: {{date}} (UTC)",
"deliveryProblem": "Die Übertragung benötigt Aufmerksamkeit. Bei Löschung oder Identitätskonflikt ist die Erfassung gestoppt. Versuche es erneut oder deaktiviere die Teilnahme, um die Daten zu löschen.",
"signingKeyUnreadable": "Der Signaturschlüssel für die Nutzungsdaten kann nicht gelesen werden. Meist wurde USAGE_ENCRYPTION_KEY — oder das als Rückfallwert genutzte JWT_SECRET — geändert. Berichte können nicht gesendet und auch die Löschanfrage kann nicht signiert werden. Stellen Sie das ursprüngliche Schlüsselmaterial wieder her, um die Löschung abzuschließen; erneutes Senden oder Deaktivieren allein behebt dies nicht.",
"inspect": "Genau sehen, was geteilt wird",
"preview": "Nächsten Bericht ansehen",
"lastPacket": "Zuletzt angenommenes signiertes Paket",
+1
View File
@@ -24,6 +24,7 @@
"hash": "Your private lookup hash",
"lastReport": "Last accepted report: {{date}} (UTC)",
"deliveryProblem": "Delivery needs attention. Collection stops during deletion or an identity conflict. Use retry, or disable participation to delete its data.",
"signingKeyUnreadable": "The usage signing key cannot be read, which usually means USAGE_ENCRYPTION_KEY — or JWT_SECRET, which it falls back to — was changed. Reports cannot be sent and the deletion request cannot be signed either. Restore the original encryption material to finish deletion; retrying or disabling will not resolve it on its own.",
"inspect": "See exactly what is shared",
"preview": "Preview next report",
"lastPacket": "Last accepted signed packet",