fix(usage): name the unreadable-key failure, unpin the collector default, align the tab

Review follow-ups on #1304.

SIGNING_KEY_UNREADABLE. USAGE_ENCRYPTION_KEY defaults to JWT_SECRET, so
rotating JWT_SECRET — the correct response to a suspected compromise —
makes the stored Ed25519 key undecryptable. That surfaced as a generic
DELIVERY_FAILED which retried forever, and it silently blocks the DELETE
packet too: an operator who withdraws has their local state cleared
while the collector keeps its copy. decrypt() now tags its own failure
and deliver() reports it under its own name, without flagging an
identity conflict — an unreadable key is not evidence of a clone. The
docs already warned that losing the key breaks deletion signing; they
now name the trigger and the error.

The collector default is no longer an inline string in the constructor.
It is a declared DEFAULT_COLLECTOR_URL, since it is a deployment choice:
self-hosters point USAGE_COLLECTOR_URL at their own collector and the UI
already derives every link from whatever is configured. schema.cjs is
deliberately untouched — it is vendored byte-identical with
picpeak-usage, and its $id is a schema identity, not a delivery address.

Links in the consent dialog. It named the collector inside prose but
never linked it, so an operator deciding whether to opt in could not
open the destination or the public schema without retyping a URL. Both
are links now, built from the configured collector.

UI standards. The tab hand-rolled its surfaces as
`<section className="rounded-xl border border-theme …">` and imported
Button from a deep path; every other settings tab uses `<Card
padding="md">` from the components/common barrel. Converted, with the
feedback <form> wrapped rather than replaced so its semantics survive,
and headings given the same colour tokens as ImageSecurityTab. The
barrel pulls ErrorBoundary -> i18n/config, so the tab's test needed the
initReactI18next shim the FaceRecognitionCard test already uses.

Not changed: the delete packet reusing the current sequence. The
collector handles delete before any sequence check — "possession proof
is sufficient for deletion, including when a restored backup has a
stale sequence" (picpeak-usage server/collector.js) — so deletion is
deliberately sequence-exempt and the client is correct as written.

Refs #1110
This commit is contained in:
Paul Nothaft
2026-09-05 21:23:22 +02:00
parent b53e5d97b4
commit c043897b0e
7 changed files with 172 additions and 24 deletions
+35 -12
View File
@@ -20,6 +20,14 @@ const {
LAYOUTS
} = require('./protocol.cjs');
// The collector this installation reports to. Declared once rather than
// inline, because it is a deployment choice: self-hosters point
// USAGE_COLLECTOR_URL at their own collector, and the admin UI links to
// whatever is configured rather than to this default. Kept out of the wire
// contract — `schema.cjs` is vendored byte-identical with picpeak-usage and
// its `$id` is a schema identity, not a delivery address.
const DEFAULT_COLLECTOR_URL = 'https://usage.picpeak.app';
const FLAG_MAP = {
crm: 'clients',
crm_quotes: 'quotes',
@@ -66,9 +74,7 @@ class UsageService {
process.env.USAGE_ENCRYPTION_KEY ||
process.env.JWT_SECRET;
this.endpoint =
options.endpoint ||
process.env.USAGE_COLLECTOR_URL ||
'https://usage.picpeak.app';
options.endpoint || process.env.USAGE_COLLECTOR_URL || DEFAULT_COLLECTOR_URL;
this.bindingPath =
options.bindingPath || path.join(getStoragePath(), 'usage-instance.key');
this.encKey = null;
@@ -116,14 +122,27 @@ class UsageService {
.join('.');
}
decrypt(value) {
const [iv, tag, data] = value
.split('.')
.map((v) => Buffer.from(v, 'base64url'));
const cipher = crypto.createDecipheriv('aes-256-gcm', this.key(), iv);
cipher.setAuthTag(tag);
return Buffer.concat([cipher.update(data), cipher.final()]).toString(
'utf8'
);
try {
const [iv, tag, data] = value
.split('.')
.map((v) => Buffer.from(v, 'base64url'));
const cipher = crypto.createDecipheriv('aes-256-gcm', this.key(), iv);
cipher.setAuthTag(tag);
return Buffer.concat([cipher.update(data), cipher.final()]).toString(
'utf8'
);
} catch (error) {
// The signing key is encrypted with USAGE_ENCRYPTION_KEY, which defaults
// to JWT_SECRET — so rotating JWT_SECRET, the correct response to a
// suspected compromise, makes this key unreadable. Without a name of its
// own that surfaced as a generic DELIVERY_FAILED that retried forever,
// and it silently blocks the DELETE packet too: participation could
// never be withdrawn from the collector. Tagged so the operator is told
// what actually happened.
const failure = new Error('Usage signing key cannot be decrypted');
failure.code = 'SIGNING_KEY_UNREADABLE';
throw failure;
}
}
async binding(create = false) {
if (create) {
@@ -381,7 +400,11 @@ class UsageService {
'NOT_REGISTERED',
'PACKET_CONFLICT'
].includes(error.code);
const code = conflict ? error.code : 'DELIVERY_FAILED';
const code = conflict
? error.code
: error.code === 'SIGNING_KEY_UNREADABLE'
? 'SIGNING_KEY_UNREADABLE'
: 'DELIVERY_FAILED';
await this.db('product_usage_state')
.where({ id: 1 })
.update({ last_error: code });