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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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'
|
||||
})
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user