From 83fbb63e13b03cd1bcb63dab0b678586d661c45a Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Sat, 5 Sep 2026 21:34:25 +0200 Subject: [PATCH] fix(usage): protect a pending withdrawal, widen the backup signal, explain an unreadable key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/src/usage/UsageService.js | 14 +++++++++++++- .../src/features/settings/tabs/ProductUsageTab.tsx | 12 +++++++++++- frontend/src/i18n/locales/de.json | 1 + frontend/src/i18n/locales/en.json | 1 + 4 files changed, 26 insertions(+), 2 deletions(-) diff --git a/backend/src/usage/UsageService.js b/backend/src/usage/UsageService.js index 40d949b9..ef74b1f7 100644 --- a/backend/src/usage/UsageService.js +++ b/backend/src/usage/UsageService.js @@ -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)) || diff --git a/frontend/src/features/settings/tabs/ProductUsageTab.tsx b/frontend/src/features/settings/tabs/ProductUsageTab.tsx index 502c71c1..e69a2e8f 100644 --- a/frontend/src/features/settings/tabs/ProductUsageTab.tsx +++ b/frontend/src/features/settings/tabs/ProductUsageTab.tsx @@ -156,7 +156,17 @@ export default function ProductUsageTab() {

{t('productUsage.lastReport', { date: data.last_report_date })}

)} {data.last_error && ( -

{t('productUsage.deliveryProblem')}

+

+ {/* 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' + )} +

)}
{data.status === 'disabled' ? ( diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index df29a7e7..bd2b1364 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -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", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 7f9d1e37..b9f7c1de 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -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",