fix(usage): close the QA findings on opt-in product usage

A QA exploration of this branch against an isolated rig — own stub
collector, SQLite and PostgreSQL — turned up one dead end and a set of
signals and controls that did not hold up. This closes all of them.

Rotating JWT_SECRET, the documented response to a suspected compromise,
made the signing key unreadable. That was already named and documented,
but it left no way out: the delete packet can never be signed, so the
row stays deletion_pending forever, and enable() refuses because it is
not `disabled`. An operator who rotated precisely because the secret was
compromised cannot restore it, so the feature was bricked with no
control left. POST /usage/abandon is offered only in that state; it
drops the local identity and records the receipt as
`collector-unconfirmed` rather than claiming a deletion that did not
happen.

Every failed delivery was retried on the next admin request, and
/activity is open to any authenticated admin while the settings ticker
fires it every five minutes per open tab — 30 activity calls against a
rejecting collector produced 30 outbound requests. Migration 206 adds
attempts/next_attempt_at and the unattended sender honours the gate;
Retry and opt-out still send immediately, and the tab names the time of
the next automatic attempt.

Feedback, votes and portal sessions now share an installation-wide
budget of 30/hour. They are the only endpoints whose effect is outbound
traffic carrying operator-written free text, and the general limiter
skips authenticated requests by design. Reading status and withdrawing
stay unthrottled.

gallery_image_protection was true on a bare install with no galleries:
PicPeak ships default_protection_level='standard' and
enable_devtools_protection=true, so it reported fleet-wide 100% and
could never separate a decision from an untouched default. It now reads
only what deviates from the shipped defaults, and the devtools flag is
not read at all — being on by default, its only informative state is
off, which is the opposite of what the key claims.

Also:
- the export receipt counted every packet and called the total "usage
  reports"; reports and participant operations are now counted and named
  separately
- GET /usage/preview no longer persists the custom_css marker, so the
  transparency view stops changing what will be sent
- the feedback route requires every field the packet schema requires,
  so an API caller gets the missing field named instead of a bare
  INVALID_PACKET from inside signing
- the German strings for this feature use "Sie" throughout, matching the
  rest of the admin UI; the ignore hint says what ignoring will do
  rather than stating it as already true
- the consent dialog returns focus to the control that opened it
- the long buttons wrap instead of running off a 390px viewport
- a deletion receipt is labelled as belonging to an earlier
  participation while a new one is active

Regression tests cover each of these, including the delete packet's
reuse of the last accepted sequence, which was an unwritten assumption
about the collector rather than a defect.
This commit is contained in:
Paul Nothaft
2026-09-06 17:40:43 +02:00
parent a7382591bf
commit 1e8b6f1b0f
20 changed files with 1076 additions and 62 deletions
@@ -57,6 +57,8 @@ async function bootDb() {
t.string('lease_token', 36);
t.bigInteger('lease_until').notNullable().defaultTo(0);
t.bigInteger('cancel_seq').notNullable().defaultTo(0);
t.integer('attempts').notNullable().defaultTo(0);
t.bigInteger('next_attempt_at').notNullable().defaultTo(0);
});
await db.schema.createTable('product_usage_markers', (t) => {
t.string('feature', 60).primary();
@@ -0,0 +1,147 @@
/**
* Two things the participant is entitled to have stated exactly.
*
* The export receipt is a privacy document — the artefact an operator shows a
* third party — so a count in it has to mean what its label says. It counted
* every packet in the participation (feedback, votes, portal sessions, the
* registration) and called the total "usage reports": an install that had sent
* one report and twenty feedback items reported twenty-one reports.
*
* The delete packet's sequence is the other: it reuses the last ACCEPTED
* sequence rather than taking the next one, unlike every other action. That is
* a contract with the collector, not an implementation detail — if the
* collector ever enforced strictly increasing sequences per installation, the
* withdrawal would be rejected forever and the operator could never leave. It
* is pinned here so the assumption is written down and a change to it has to
* be deliberate.
*/
const knex = require('knex');
const { UsageService } = require('../../src/usage/UsageService');
const { generateIdentity, verifyEnvelope } = require('../../src/usage/protocol.cjs');
const SECRET = 's'.repeat(48);
async function bootDb() {
const db = knex({
client: 'sqlite3',
connection: { filename: ':memory:' },
useNullAsDefault: true,
});
await db.schema.createTable('product_usage_state', (t) => {
t.integer('id').primary();
t.string('status', 30).notNullable().defaultTo('disabled');
t.string('consent_version', 40).notNullable().defaultTo('usage-consent.v2');
t.boolean('notice_dismissed').notNullable().defaultTo(false);
t.string('installation_id', 64);
t.string('public_key', 59);
t.text('private_key_encrypted');
t.string('instance_binding', 64);
t.bigInteger('sequence').notNullable().defaultTo(0);
t.text('pending_packet');
t.text('last_packet');
t.text('last_receipt');
t.text('privacy_receipts');
t.string('last_report_date', 10);
t.string('last_error', 80);
t.text('feedback_preferences');
t.string('lease_token', 36);
t.bigInteger('lease_until').notNullable().defaultTo(0);
t.bigInteger('cancel_seq').notNullable().defaultTo(0);
t.integer('attempts').notNullable().defaultTo(0);
t.bigInteger('next_attempt_at').notNullable().defaultTo(0);
});
await db('product_usage_state').insert({ id: 1 });
await db.schema.createTable('product_usage_markers', (t) => t.string('feature', 60).primary());
return db;
}
const envelope = (action) => ({ packet: { action, installation_id: 'a'.repeat(64) } });
describe('the export receipt states what it actually counted', () => {
let db;
afterEach(async () => { if (db) await db.destroy(); db = null; });
const exportWith = async (packets) => {
db = await bootDb();
await db('product_usage_state').where({ id: 1 }).update({
status: 'active',
installation_id: 'a'.repeat(64),
});
const service = new UsageService(db, {
secret: SECRET,
endpoint: 'https://usage.example.test',
now: () => Date.parse('2026-09-06T12:00:00.000Z'),
fetch: async () => ({
ok: true,
headers: { get: () => null },
body: (async function* () {
yield Buffer.from(JSON.stringify({ installation_id: 'a'.repeat(64), packets }));
})(),
}),
});
await service.export();
return JSON.parse((await db('product_usage_state').where({ id: 1 }).first()).privacy_receipts)
.last_export;
};
it('counts reports as reports and everything else separately', async () => {
const receipt = await exportWith([
envelope('register'),
envelope('report'),
envelope('consent'),
envelope('feedback'),
envelope('feedback'),
envelope('vote'),
envelope('session'),
]);
expect(receipt.report_count).toBe(1);
expect(receipt.packet_count).toBe(7);
expect(receipt.scope).toEqual([
'accepted usage reports',
'accepted participant operations',
]);
});
it('reports zero rather than a total when no report was ever accepted', async () => {
const receipt = await exportWith([envelope('register'), envelope('feedback')]);
expect(receipt.report_count).toBe(0);
expect(receipt.packet_count).toBe(2);
});
});
describe('the delete packet reuses the last accepted sequence', () => {
let db;
afterEach(async () => { if (db) await db.destroy(); db = null; });
it('sends the accepted sequence, not the next one', async () => {
db = await bootDb();
const identity = generateIdentity();
const sent = [];
const service = new UsageService(db, {
secret: SECRET,
endpoint: 'https://usage.example.test',
now: () => Date.parse('2026-09-06T12:00:00.000Z'),
bindingPath: `${require('os').tmpdir()}/usage-delete-seq-${Date.now()}.key`,
fetch: async (_url, options) => {
const body = JSON.parse(options.body);
sent.push(verifyEnvelope(body, Date.parse('2026-09-06T12:00:00.000Z')));
// Deliberately not a sequence-enforcing collector: this test pins what
// PicPeak sends, and the collector contract is what must match it.
throw new Error('stop after capturing the packet');
},
});
await db('product_usage_state').where({ id: 1 }).update({
status: 'deletion_pending',
installation_id: identity.installation_id,
public_key: identity.public_key,
private_key_encrypted: service.encrypt(identity.private_key),
sequence: 7,
});
await service.tick({ force: true });
expect(sent).toHaveLength(1);
expect(sent[0].action).toBe('delete');
expect(sent[0].sequence).toBe(7);
});
});
@@ -10,6 +10,7 @@
*/
const knex = require('knex');
const { UsageService } = require('../../src/usage/UsageService');
const { generateIdentity, makePacket } = require('../../src/usage/protocol.cjs');
const SECRET_A = 'a'.repeat(48);
const SECRET_B = 'b'.repeat(48);
@@ -39,6 +40,8 @@ async function bootDb() {
t.text('feedback_preferences');
t.string('lease_token', 36);
t.bigInteger('lease_until').notNullable().defaultTo(0);
t.integer('attempts').notNullable().defaultTo(0);
t.bigInteger('next_attempt_at').notNullable().defaultTo(0);
});
await db('product_usage_state').insert({ id: 1 });
return db;
@@ -94,3 +97,166 @@ describe('usage signing key becomes unreadable after secret rotation', () => {
expect(row.status).toBe('active');
});
});
/**
* Naming the failure told the operator what happened but left them nowhere to
* go: the delete packet can never be signed, so the row stays in
* deletion_pending forever, and enable() refuses because it is not `disabled`.
* An operator who rotated the secret precisely because it was compromised
* cannot restore it, so without an exit the feature is bricked.
*/
describe('abandoning a withdrawal that can never be signed', () => {
let db;
afterEach(async () => { if (db) await db.destroy(); db = null; });
const stuck = async () => {
db = await bootDb();
await db.schema.createTable('product_usage_markers', (t) => {
t.string('feature', 60).primary();
});
await db('product_usage_markers').insert({ feature: 'crm' });
await db('product_usage_state').where({ id: 1 }).update({
status: 'deletion_pending',
installation_id: 'a'.repeat(64),
public_key: 'p'.repeat(59),
private_key_encrypted: new UsageService(db, { secret: SECRET_A }).encrypt('key'),
sequence: 4,
last_error: 'SIGNING_KEY_UNREADABLE',
});
return new UsageService(db, {
secret: SECRET_B,
endpoint: 'https://usage.example.test',
bindingPath: `${require('os').tmpdir()}/usage-abandon-${Date.now()}.key`,
fetch: () => { throw new Error('network must not be reached'); },
});
};
it('clears the local identity and records the deletion as unconfirmed', async () => {
const service = await stuck();
const status = await service.abandon();
expect(status.status).toBe('disabled');
expect(status.installation_id).toBeNull();
const row = await db('product_usage_state').where({ id: 1 }).first();
expect(row.private_key_encrypted).toBeNull();
expect(row.public_key).toBeNull();
expect(row.last_error).toBeNull();
expect(await db('product_usage_markers').count('* as c').first()).toEqual({ c: 0 });
// The receipt must not claim a deletion the collector never confirmed.
const receipt = JSON.parse(row.privacy_receipts).last_abandonment;
expect(receipt.status).toBe('collector-unconfirmed');
expect(receipt.reason).toBe('SIGNING_KEY_UNREADABLE');
expect(receipt.installation_id).toBe('a'.repeat(64));
});
it('lets the operator rejoin afterwards', async () => {
const service = await stuck();
await service.abandon();
expect((await service.state()).status).toBe('disabled');
});
it('refuses on a withdrawal that is merely undelivered', async () => {
const service = await stuck();
await db('product_usage_state').where({ id: 1 }).update({ last_error: 'DELIVERY_FAILED' });
await expect(service.abandon()).rejects.toThrow(/abandoned/);
expect((await service.state()).installation_id).toBe('a'.repeat(64));
});
it('refuses while participation is active', async () => {
const service = await stuck();
await db('product_usage_state').where({ id: 1 }).update({ status: 'active' });
await expect(service.abandon()).rejects.toThrow(/abandoned/);
expect((await service.state()).installation_id).toBe('a'.repeat(64));
});
});
/**
* Every failed delivery used to be retried on the next admin request, and
* /activity is open to any authenticated admin while the settings ticker fires
* it every five minutes per open tab. A permanently rejected packet therefore
* produced one collector request per admin action, indefinitely.
*/
describe('delivery backoff', () => {
let db;
afterEach(async () => { if (db) await db.destroy(); db = null; });
// A real identity and a schema-valid packet, so the failure happens where
// this test claims it does — at the network — rather than in signPacket.
const activeWithPendingPacket = async (fetchImpl, now) => {
db = await bootDb();
await db.schema.createTable('product_usage_markers', (t) => {
t.string('feature', 60).primary();
});
const service = new UsageService(db, {
secret: SECRET_A,
endpoint: 'https://usage.example.test',
now: () => now(),
fetch: fetchImpl,
});
const identity = generateIdentity();
await db('product_usage_state').where({ id: 1 }).update({
status: 'active',
consent_version: 'usage-consent.v2',
installation_id: identity.installation_id,
public_key: identity.public_key,
private_key_encrypted: service.encrypt(identity.private_key),
sequence: 1,
pending_packet: JSON.stringify(
makePacket(identity, 'session', 2, {}, 'usage.v2')
),
});
return service;
};
it('paces the next unattended attempt after a failure, and lets Retry skip it', async () => {
let clock = 1_000_000;
let calls = 0;
const service = await activeWithPendingPacket(() => {
calls += 1;
throw new Error('collector unreachable');
}, () => clock);
await service.tick();
expect(calls).toBe(1);
const paced = await service.state();
expect(Number(paced.attempts)).toBe(1);
expect(Number(paced.next_attempt_at)).toBeGreaterThan(clock);
// The unattended callers — /activity and the settings ticker — wait.
await service.tick();
await service.tick();
expect(calls).toBe(1);
// The operator pressing Retry does not.
await service.tick({ force: true });
expect(calls).toBe(2);
expect(Number((await service.state()).attempts)).toBe(2);
// Once the window passes, the automatic sender tries again on its own.
clock = Number((await service.state()).next_attempt_at) + 1;
await service.tick();
expect(calls).toBe(3);
});
it('grows the wait with consecutive failures and caps it at an hour', () => {
const service = new UsageService(null, { secret: SECRET_A, endpoint: 'https://usage.example.test' });
expect(service.backoffMs(1)).toBe(2 * 60000);
expect(service.backoffMs(3)).toBe(8 * 60000);
expect(service.backoffMs(20)).toBe(60 * 60000);
});
it('clears the pacing once a packet is accepted', async () => {
const clock = 1_000_000;
const service = await activeWithPendingPacket(async () => {
throw new Error('collector unreachable');
}, () => clock);
await service.tick();
expect(Number((await service.state()).attempts)).toBe(1);
await service.clearDeliveryBackoff();
const cleared = await service.state();
expect(Number(cleared.attempts)).toBe(0);
expect(Number(cleared.next_attempt_at)).toBe(0);
});
});
@@ -34,6 +34,8 @@ async function bootDb() {
t.text('feedback_preferences'); t.string('lease_token', 36);
t.bigInteger('lease_until').notNullable().defaultTo(0);
t.bigInteger('cancel_seq').notNullable().defaultTo(0);
t.integer('attempts').notNullable().defaultTo(0);
t.bigInteger('next_attempt_at').notNullable().defaultTo(0);
});
await db('product_usage_state').insert({ id: 1 });
await db.schema.createTable('product_usage_markers', (t) => t.string('feature', 60).primary());
@@ -196,6 +198,116 @@ describe('S3 use is only implied by backups that write to the destination', () =
});
});
/**
* A signal whose answer is fixed by the shipped defaults is not a signal.
* PicPeak ships default_protection_level='standard' and
* enable_devtools_protection=true, so accepting either as evidence made
* gallery_image_protection true on a bare install with no galleries — a
* fleet-wide 100% that cannot separate a decision from an untouched default.
*/
describe('gallery_image_protection reports decisions, not shipped defaults', () => {
let db;
afterEach(async () => { if (db) await db.destroy(); db = null; });
const v2 = async () => {
db = await bootDb();
await db.schema.alterTable('events', (t) => {
for (const column of ['disable_right_click', 'enable_devtools_protection', 'use_canvas_rendering']) t.boolean(column);
t.string('protection_level');
});
await db('product_usage_state').where({ id: 1 })
.update({ status: 'active', consent_version: 'usage-consent.v2' });
return service(db);
};
const shipped = async () => {
// Exactly what migration 038 seeds, plus an event carrying the column
// defaults from the same migration.
await db('app_settings').insert([
{ setting_key: 'default_protection_level', setting_value: '"standard"' },
{ setting_key: 'enable_devtools_protection', setting_value: 'true' },
{ setting_key: 'enable_canvas_rendering', setting_value: 'false' },
]);
await db('events').insert({
protection_level: 'standard',
enable_devtools_protection: true,
use_canvas_rendering: false,
disable_right_click: false,
});
};
it('is false on a bare install with no galleries at all', async () => {
const client = await v2();
expect((await client.snapshot()).features.gallery_image_protection)
.toEqual({ configured: false });
});
it('is false when every value is still the shipped default', async () => {
const client = await v2();
await shipped();
expect((await client.snapshot()).features.gallery_image_protection)
.toEqual({ configured: false });
});
it('ignores the devtools flag entirely, since it ships on', async () => {
const client = await v2();
await shipped();
// Turning it OFF is the only informative state it has, and that is the
// opposite of what this key claims — so neither state may set it.
await db('app_settings').where({ setting_key: 'enable_devtools_protection' })
.update({ setting_value: 'false' });
await db('events').update({ enable_devtools_protection: false });
expect((await client.snapshot()).features.gallery_image_protection)
.toEqual({ configured: false });
});
it.each([
['a stronger global level', async (db) => db('app_settings').where({ setting_key: 'default_protection_level' }).update({ setting_value: '"maximum"' })],
['global canvas rendering', async (db) => db('app_settings').where({ setting_key: 'enable_canvas_rendering' }).update({ setting_value: 'true' })],
['a stronger level on one gallery', async (db) => db('events').update({ protection_level: 'enhanced' })],
['canvas rendering on one gallery', async (db) => db('events').update({ use_canvas_rendering: true })],
['right-click disabled on one gallery', async (db) => db('events').update({ disable_right_click: true })],
])('is true for %s', async (_label, change) => {
const client = await v2();
await shipped();
await change(db);
expect((await client.snapshot()).features.gallery_image_protection)
.toEqual({ configured: true });
});
});
/**
* The settings preview is the "see exactly what would be sent" view. It shared
* snapshot() with the real sender, and snapshot() records applied custom CSS
* as a lifetime marker — so reading the transparency view wrote a marker.
*/
describe('preview does not change what will be sent', () => {
let db;
afterEach(async () => { if (db) await db.destroy(); db = null; });
const withAppliedCss = async () => {
db = await bootDb();
await db('product_usage_state').where({ id: 1 })
.update({ status: 'active', consent_version: 'usage-consent.v2' });
await db('app_settings').insert({
setting_key: 'general_custom_css', setting_value: '".x{}"'
});
return service(db);
};
it('reports custom_css as used without persisting the marker', async () => {
const client = await withAppliedCss();
const preview = await client.preview();
expect(preview.features.custom_css).toEqual({ configured: true, used: true });
expect(await db('product_usage_markers').pluck('feature')).toEqual([]);
});
it('still persists it when the sender builds the real report', async () => {
const client = await withAppliedCss();
await client.snapshot();
expect(await db('product_usage_markers').pluck('feature')).toEqual(['custom_css']);
});
});
describe('v2 technical configuration and privacy boundaries', () => {
let db;
let savedEnv;