fix(usage): let an operator clear a participation the collector never accepted

Probing the live collector to settle the delete-sequence question turned up
something else: usage.picpeak.app answers a valid usage.v2 registration with
INVALID_PACKET while the identical v1 flow is accepted. It does not speak v2
yet — which the deployment notes already require, but the consequence of
getting that order wrong was worse than "reports do not send".

Opting in to v2 against a v1-only collector left the installation stuck.
Registration was refused, so nothing existed at the collector at all; the row
sat in activation_pending, disable moved it to deletion_pending, retry was
futile forever, and enable refused because the row was not `disabled`. The
abandon hatch added earlier did not apply: it was gated on
SIGNING_KEY_UNREADABLE. So the most harmless possible failure — nothing
registered anywhere — was the one an operator could not clear.

The gate is now the property that actually matters: a participation the
collector has provably never accepted (sequence 0, no receipt) with a failing
delivery can be discarded, from activation_pending as well as
deletion_pending. Its receipt records `never-registered` rather than an
unconfirmed deletion, because nothing remote exists to be unsure about. A
participation the collector *did* accept keeps the old narrow gate and its
explicit warning — clearing local state while the collector still holds
reports must stay a deliberate, warned-about act.

A collector that rejects a registration or a deletion outright now reports
SCHEMA_NOT_ACCEPTED instead of DELIVERY_FAILED, and the settings page says the
collector does not accept this report version yet. Retrying cannot fix that,
and sending the operator to look for a network fault they do not have was
wrong.

Verified end to end against the live collector: v2 opt-in reports
SCHEMA_NOT_ACCEPTED, the exit is offered immediately, the receipt says
never-registered, and joining again on v1 registers, reports and withdraws
with a collector-confirmed deletion.
This commit is contained in:
Paul Nothaft
2026-09-06 18:54:58 +02:00
parent c741dc22c5
commit e40bc474bc
7 changed files with 186 additions and 22 deletions
@@ -260,3 +260,100 @@ describe('delivery backoff', () => {
expect(Number(cleared.next_attempt_at)).toBe(0);
});
});
/**
* The same dead end, reached the ordinary way. If an installation opts in to
* usage.v2 while the collector still only speaks usage.v1 — the deployment
* order the docs warn about — the registration is rejected outright. Nothing
* exists at the collector, and yet the operator could not clear the tab:
* disable moved to deletion_pending, retry was futile, enable refused, and the
* abandon hatch was gated on SIGNING_KEY_UNREADABLE, which this is not.
*
* Verified against the live collector before this was written: a valid v2
* register is answered with INVALID_PACKET while the identical v1 flow is
* accepted.
*/
describe('a participation the collector never accepted', () => {
let db;
afterEach(async () => { if (db) await db.destroy(); db = null; });
const rejectingCollector = async (status) => {
db = await bootDb();
await db.schema.createTable('product_usage_markers', (t) => {
t.string('feature', 60).primary();
});
const identity = generateIdentity();
const service = new UsageService(db, {
secret: SECRET_A,
endpoint: 'https://usage.example.test',
bindingPath: `${require('os').tmpdir()}/usage-unreg-${Date.now()}-${Math.random()}.key`,
fetch: async () => ({
ok: false,
status: 400,
headers: { get: () => null },
body: (async function* () { yield Buffer.from(JSON.stringify({ error: 'INVALID_PACKET' })); })(),
}),
});
await db('product_usage_state').where({ id: 1 }).update({
status,
consent_version: 'usage-consent.v2',
installation_id: identity.installation_id,
public_key: identity.public_key,
private_key_encrypted: service.encrypt(identity.private_key),
sequence: 0,
pending_packet: JSON.stringify(
makePacket(identity, status === 'deletion_pending' ? 'delete' : 'register', 0,
status === 'deletion_pending' ? {} : { consent_version: 'usage-consent.v2' }, 'usage.v2')
),
});
return service;
};
it('names the rejection instead of blaming the network', async () => {
const service = await rejectingCollector('activation_pending');
await service.tick({ force: true });
expect((await service.state()).last_error).toBe('SCHEMA_NOT_ACCEPTED');
});
it('offers the exit straight from activation_pending', async () => {
const service = await rejectingCollector('activation_pending');
await service.tick({ force: true });
const status = await service.status();
expect(status.can_abandon).toBe(true);
expect(status.abandon_never_registered).toBe(true);
await service.abandon();
const after = await service.state();
expect(after.status).toBe('disabled');
expect(after.installation_id).toBeNull();
// Provably nothing remote, so the receipt must not hedge.
expect(JSON.parse(after.privacy_receipts).last_abandonment.status)
.toBe('never-registered');
});
it('offers it from deletion_pending too, once the withdrawal is also undeliverable', async () => {
const service = await rejectingCollector('deletion_pending');
await service.tick({ force: true });
expect((await service.status()).can_abandon).toBe(true);
await service.abandon();
expect((await service.state()).status).toBe('disabled');
});
it('never offers it while a registered participation could still be deleted remotely', async () => {
const service = await rejectingCollector('deletion_pending');
// Something WAS accepted once: the collector may still hold reports, so
// clearing local state silently would be a lie.
await db('product_usage_state').where({ id: 1 }).update({
sequence: 3,
last_receipt: JSON.stringify({ status: 'accepted' }),
last_error: 'DELIVERY_FAILED',
});
expect((await service.status()).can_abandon).toBe(false);
await expect(service.abandon()).rejects.toThrow(/cannot be completed/);
});
it('does not offer it before a delivery has actually failed', async () => {
const service = await rejectingCollector('activation_pending');
expect((await service.status()).can_abandon).toBe(false);
});
});
+51 -17
View File
@@ -106,6 +106,29 @@ const parse = (value) => {
};
class UsageService {
// The collector has provably never accepted anything from this identity:
// no packet was ever acknowledged, so there is nothing remote to delete.
// Abandoning such a participation is harmless, which is why it may be
// offered without the warning the registered case needs.
static neverAccepted(state) {
return Number(state?.sequence || 0) === 0 && !state?.last_receipt;
}
// The two ways a participation can reach a state no amount of retrying will
// resolve. Kept as one predicate so the settings page and the endpoint can
// never disagree about whether the exit is available.
static abandonable(state) {
if (!['activation_pending', 'deletion_pending'].includes(state?.status))
return false;
// The signing key is gone: the delete packet can never be produced.
if (state.last_error === 'SIGNING_KEY_UNREADABLE') return true;
// Or the collector never accepted anything and a delivery is failing —
// the ordinary shape of "opted in against a collector that does not speak
// this report version yet". Nothing is registered remotely, so clearing
// the local state costs nothing and is the only way out of the tab.
return Boolean(state.last_error) && UsageService.neverAccepted(state);
}
schemaVersion(state) {
return state?.consent_version === CURRENT_CONSENT_VERSION
? CURRENT_SCHEMA_VERSION : 'usage.v1';
@@ -254,14 +277,12 @@ class UsageService {
Number(state.next_attempt_at || 0) > this.now()
? Number(state.next_attempt_at)
: null,
// The one failure the operator cannot retry their way out of: the
// signing key is unreadable, so the delete packet can never be signed.
// Without this flag the settings page has no way to offer the only
// remaining exit (abandon), and the install sits in deletion_pending
// forever.
can_abandon:
state.status === 'deletion_pending' &&
state.last_error === 'SIGNING_KEY_UNREADABLE',
// Whether the only remaining exit should be offered. Without it the tab
// shows a permanent error and no control that can clear it.
can_abandon: UsageService.abandonable(state),
// Distinguishes the harmless case (nothing was ever registered, so
// abandoning deletes nothing remote) from the one that needs a warning.
abandon_never_registered: UsageService.neverAccepted(state),
pending_action: state.pending_packet
? JSON.parse(state.pending_packet).action
: null,
@@ -433,13 +454,11 @@ class UsageService {
// unconfirmed deletion is its own decision, not a side effect of opting in.
async abandon() {
await this.locked(async (state) => {
if (
state.status !== 'deletion_pending' ||
state.last_error !== 'SIGNING_KEY_UNREADABLE'
)
if (!UsageService.abandonable(state))
throw new ConflictError(
'Only an unsignable withdrawal can be abandoned'
'Only a participation that cannot be completed can be abandoned'
);
const neverRegistered = UsageService.neverAccepted(state);
await fs.unlink(this.bindingPath).catch((error) => {
if (error.code !== 'ENOENT') throw error;
});
@@ -448,7 +467,8 @@ class UsageService {
? JSON.parse(state.privacy_receipts)
: {};
await this.db('product_usage_state')
.where({ id: 1, status: 'deletion_pending' })
.where({ id: 1 })
.whereIn('status', ['activation_pending', 'deletion_pending'])
.update({
status: 'disabled',
installation_id: null,
@@ -471,8 +491,15 @@ class UsageService {
kind: 'abandonment',
receipt_id: crypto.randomUUID(),
confirmed_at: new Date(this.now()).toISOString(),
status: 'collector-unconfirmed',
reason: 'SIGNING_KEY_UNREADABLE',
// Two different truths, and the receipt has to tell them apart.
// Nothing was ever accepted -> there is provably nothing at the
// collector. Otherwise the collector may still hold reports and
// was never told to delete them; saying "unconfirmed" is the
// only honest wording for that.
status: neverRegistered
? 'never-registered'
: 'collector-unconfirmed',
reason: state.last_error,
installation_id: state.installation_id,
scope: ['local identity', 'local markers', 'local key material']
}
@@ -681,11 +708,18 @@ class UsageService {
'NOT_REGISTERED',
'PACKET_CONFLICT'
].includes(error.code);
// A collector that answers INVALID_PACKET to a registration or a deletion
// does not understand the wire version we speak — most often because it
// has not been upgraded to usage.v2 yet. Retrying cannot fix that, and
// reporting it as DELIVERY_FAILED sent the operator looking for a
// network problem they do not have.
const code = conflict
? error.code
: error.code === 'SIGNING_KEY_UNREADABLE'
? 'SIGNING_KEY_UNREADABLE'
: 'DELIVERY_FAILED';
: rejected && ['register', 'delete'].includes(packet.action)
? 'SCHEMA_NOT_ACCEPTED'
: 'DELIVERY_FAILED';
await this.db('product_usage_state')
.where({ id: 1 })
.update({ last_error: code });
+14 -1
View File
@@ -77,7 +77,20 @@ 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
again afterwards with a fresh identity.
The same exit covers the other way a participation can become impossible to
finish: a collector that rejects the packet outright. Opting in to usage.v2
against a collector that still only speaks usage.v1 — the deployment order
this document warns about above — is answered with `INVALID_PACKET`, which is
surfaced as `SCHEMA_NOT_ACCEPTED` rather than a generic delivery failure,
because retrying cannot resolve it. Nothing is registered in that case, so
**Discard local identity** is offered immediately and its receipt records
`never-registered` rather than an unconfirmed deletion. The exit is never
offered while a participation the collector *did* accept could still be
deleted remotely; that case keeps the explicit warning.
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.
@@ -278,7 +278,9 @@ export default function ProductUsageTab() {
{t(
data.last_error === 'SIGNING_KEY_UNREADABLE'
? 'productUsage.signingKeyUnreadable'
: 'productUsage.deliveryProblem'
: data.last_error === 'SCHEMA_NOT_ACCEPTED'
? 'productUsage.schemaNotAccepted'
: 'productUsage.deliveryProblem'
)}
</p>
)}
@@ -296,7 +298,13 @@ export default function ProductUsageTab() {
// The one dead end the operator cannot retry out of. Offered only
// here, and worded so nobody mistakes it for a confirmed deletion.
<div className="rounded border border-amber-300 dark:border-amber-700 p-3 space-y-2">
<p>{t('productUsage.abandonExplanation')}</p>
<p>
{t(
data.abandon_never_registered
? 'productUsage.abandonExplanationUnregistered'
: 'productUsage.abandonExplanation'
)}
</p>
<Button
variant="outline"
className={WRAPPING_BUTTON}
@@ -305,7 +313,11 @@ export default function ProductUsageTab() {
if (
await confirm({
title: t('productUsage.abandon'),
message: t('productUsage.abandonConfirm'),
message: t(
data.abandon_never_registered
? 'productUsage.abandonConfirmUnregistered'
: 'productUsage.abandonConfirm'
),
confirmLabel: t('productUsage.abandon'),
variant: 'danger'
})
+3
View File
@@ -402,6 +402,7 @@
"deliveryProblem": "Die Übertragung benötigt Aufmerksamkeit. Bei Löschung oder Identitätskonflikt ist die Erfassung gestoppt. Versuchen Sie es erneut oder deaktivieren Sie die Teilnahme, um die Daten zu löschen.",
"invalidCollectorUrl": "Die konfigurierte Collector-URL ist ungültig, daher kann die Teilnahme weder gestartet noch übermittelt werden. Setzen Sie USAGE_COLLECTOR_URL auf einen https-Origin ohne Pfad, Query oder Zugangsdaten (oder lassen Sie sie leer, um den Standard zu verwenden).",
"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.",
"schemaNotAccepted": "Der Collector hat das Paket rundheraus abgelehnt — er nimmt diese Berichtsversion also noch nicht an, meist weil er nicht aktualisiert wurde. Erneutes Senden ändert daran nichts. Es wurde nichts registriert; Sie können die Teilnahme verwerfen und erneut beitreten, sobald der Collector sie unterstützt.",
"inspect": "Genau sehen, was geteilt wird",
"preview": "Nächsten Bericht ansehen",
"lastPacket": "Zuletzt angenommener signierter Nutzungsbericht",
@@ -443,7 +444,9 @@
"retryScheduled": "Der nächste automatische Versuch erfolgt um {{time}}. „Erneut versuchen“ sendet sofort.",
"abandon": "Lokale Identität verwerfen",
"abandonExplanation": "Die Löschanfrage kann ohne das ursprüngliche Schlüsselmaterial nicht signiert werden. Wenn Sie es nicht wiederherstellen können, lässt sich die lokale Identität verwerfen: Erfassung und Schlüssel werden hier entfernt, der Collector bestätigt die Löschung dabei aber nicht.",
"abandonExplanationUnregistered": "Diese Teilnahme wurde vom Collector nie angenommen, dort ist also nichts gespeichert und es gibt nichts zu löschen. Sie können sie hier verwerfen und jederzeit neu beginnen.",
"abandonConfirm": "Installationsidentität, Schlüsselmaterial und alle lokalen Marker werden gelöscht. Der Collector wird nicht benachrichtigt und behält die bisher gesendeten Berichte — die Quittung hält das als unbestätigt fest. Danach ist eine neue Teilnahme wieder möglich.",
"abandonConfirmUnregistered": "Installationsidentität, Schlüsselmaterial und alle lokalen Marker werden gelöscht. Der Collector hat diese Teilnahme nie angenommen, es wird also nirgendwo sonst etwas entfernt. Danach ist eine neue Teilnahme wieder möglich.",
"auditPreviousParticipation": "Löschbestätigungen beziehen sich auf eine frühere Teilnahme, nicht auf die aktuelle."
},
"userManagement": {
+3
View File
@@ -402,6 +402,7 @@
"deliveryProblem": "Delivery needs attention. Collection stops during deletion or an identity conflict. Use retry, or disable participation to delete its data.",
"invalidCollectorUrl": "The configured usage collector URL is not valid, so participation cannot be started or delivered. Set USAGE_COLLECTOR_URL to an https origin with no path, query or credentials (or leave it unset to use the default).",
"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.",
"schemaNotAccepted": "The collector rejected the packet outright, which means it does not accept this report version yet — usually a collector that has not been upgraded. Retrying will not change that. Nothing has been registered, so you can discard the participation and join again once the collector supports it.",
"inspect": "See exactly what is shared",
"preview": "Preview next report",
"lastPacket": "Last accepted signed usage report",
@@ -443,7 +444,9 @@
"retryScheduled": "The next automatic attempt is at {{time}}. \"Retry\" sends immediately.",
"abandon": "Discard local identity",
"abandonExplanation": "The deletion request cannot be signed without the original encryption material. If you cannot restore it, you can discard the local identity: collection and keys are removed here, but the collector does not confirm the deletion.",
"abandonExplanationUnregistered": "This participation was never accepted by the collector, so nothing is stored there and there is nothing to delete. You can discard it here and start again at any time.",
"abandonConfirm": "This deletes the installation identity, the key material and every local marker. The collector is not notified and keeps the reports already sent — the receipt records that as unconfirmed. You can join again afterwards.",
"abandonConfirmUnregistered": "This deletes the local installation identity, the key material and every local marker. The collector never accepted this participation, so nothing is removed anywhere else. You can join again afterwards.",
"auditPreviousParticipation": "Deletion confirmations refer to an earlier participation, not the current one."
},
"userManagement": {
@@ -19,8 +19,10 @@ export interface UsageStatus {
last_error: string | null;
/** Epoch ms the paced sender is waiting for, or null when nothing is paced. */
retry_after?: number | null;
/** True only for a withdrawal whose delete packet can never be signed. */
/** True when the participation cannot be completed and the only exit is to discard it. */
can_abandon?: boolean;
/** True when the collector never accepted anything, so discarding deletes nothing remote. */
abandon_never_registered?: boolean;
pending_action: string | null;
last_packet: unknown;
privacy_receipts?: Record<string, unknown>;