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
@@ -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;