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:
@@ -0,0 +1,31 @@
|
||||
// Retry pacing for the collector. Without it every failed packet was retried
|
||||
// on the next admin request: /activity is open to any authenticated admin and
|
||||
// the settings ticker fires it every five minutes per open tab, so an
|
||||
// installation whose packet the collector rejects permanently hammered it
|
||||
// once per admin action, forever, with a failing request sitting on the
|
||||
// critical path of that action.
|
||||
//
|
||||
// `attempts` counts consecutive failures and `next_attempt_at` is the epoch-ms
|
||||
// gate the automatic sender honours. Explicit operator actions — Retry and
|
||||
// Disable — pass through regardless; the point is to pace the unattended loop,
|
||||
// not to make the admin wait out a backoff they asked to skip.
|
||||
exports.up = async function (knex) {
|
||||
if (!(await knex.schema.hasTable('product_usage_state'))) return;
|
||||
if (!(await knex.schema.hasColumn('product_usage_state', 'attempts')))
|
||||
await knex.schema.alterTable('product_usage_state', (t) => {
|
||||
t.integer('attempts').notNullable().defaultTo(0);
|
||||
});
|
||||
if (!(await knex.schema.hasColumn('product_usage_state', 'next_attempt_at')))
|
||||
await knex.schema.alterTable('product_usage_state', (t) => {
|
||||
t.bigInteger('next_attempt_at').notNullable().defaultTo(0);
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = async function (knex) {
|
||||
if (!(await knex.schema.hasTable('product_usage_state'))) return;
|
||||
for (const column of ['attempts', 'next_attempt_at'])
|
||||
if (await knex.schema.hasColumn('product_usage_state', column))
|
||||
await knex.schema.alterTable('product_usage_state', (t) => {
|
||||
t.dropColumn(column);
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user