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
@@ -18,6 +18,7 @@ const knex = require('knex');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { generateIdentity, makePacket } = require('../../src/usage/protocol.cjs');
const PG_URL = process.env.PICPEAK_PG_TEST_URL;
const maybe = PG_URL ? describe : describe.skip;
@@ -52,6 +53,7 @@ maybe('product usage on Postgres', () => {
await require('../../migrations/core/203_product_usage_cancel_seq').up(db);
await require('../../migrations/core/204_product_usage_privacy_receipts').up(db);
await require('../../migrations/core/205_product_usage_consent_version').up(db);
await require('../../migrations/core/206_product_usage_delivery_backoff').up(db);
await db.schema.createTable('app_settings', (t) => {
t.string('setting_key').primary(); t.text('setting_value'); t.string('setting_type');
@@ -114,6 +116,56 @@ maybe('product usage on Postgres', () => {
expect(cols.sequence).toBeDefined();
expect(cols.privacy_receipts).toBeDefined();
expect(cols.consent_version).toBeDefined();
// next_attempt_at is a bigint like sequence and cancel_seq, so pg hands it
// back as a STRING — the tick() gate compares it against a number.
expect(cols.attempts).toBeDefined();
expect(cols.next_attempt_at).toBeDefined();
});
it('reruns the backoff migration safely', async () => {
const migration = require('../../migrations/core/206_product_usage_delivery_backoff');
await migration.up(db);
await migration.up(db);
const row = await db('product_usage_state').where({ id: 1 }).first();
expect(Number(row.attempts)).toBe(0);
expect(Number(row.next_attempt_at)).toBe(0);
});
it('honours the retry gate even though pg returns next_attempt_at as a string', async () => {
let clock = 5_000_000;
let calls = 0;
const identity = generateIdentity();
const client = service({
now: () => clock,
fetch: async () => { calls += 1; throw new Error('collector unreachable'); },
});
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: client.encrypt(identity.private_key),
sequence: 1,
attempts: 0,
next_attempt_at: 0,
pending_packet: JSON.stringify(makePacket(identity, 'session', 2, {}, 'usage.v2')),
});
await client.tick();
expect(calls).toBe(1);
const paced = await db('product_usage_state').where({ id: 1 }).first();
// A '5000120000' > 5000000 string comparison would be a different answer.
expect(typeof paced.next_attempt_at).toBe('string');
await client.tick();
expect(calls).toBe(1);
clock = Number(paced.next_attempt_at) + 1;
await client.tick();
expect(calls).toBe(2);
await db('product_usage_state').where({ id: 1 }).update({
status: 'disabled', pending_packet: null, attempts: 0, next_attempt_at: 0,
});
});
it('reruns the receipt migration safely and scrubs legacy plaintext sessions', async () => {
@@ -31,6 +31,7 @@ jest.mock('../../src/services/productUsageService', () =>
'dismiss',
'enable',
'disable',
'abandon',
'preview',
'export',
'preferences',
@@ -125,6 +126,7 @@ const ROUTES = [
['post', '/enable'],
['post', '/consent'],
['post', '/disable'],
['post', '/abandon'],
['post', '/retry'],
['post', '/dismiss'],
['get', '/preview'],
@@ -230,3 +232,89 @@ test('only a backup that writes to the configured destination flags S3', () => {
['/backup/picpeak/export', false],
]);
});
// The route allowlist and the packet schema have to agree. The allowlist used
// to let `name`, `allow_public` and `allow_marketing` be omitted while the
// schema requires all three, so an API caller got a bare INVALID_PACKET from
// deep inside signing instead of being told which field was missing.
const VALID_FEEDBACK = {
kind: 'feedback',
title: 'Title',
body: 'Body',
name: '',
allow_public: false,
allow_marketing: false
};
test.each([
['no body at all', {}],
['missing name', { ...VALID_FEEDBACK, name: undefined }],
['missing allow_public', { ...VALID_FEEDBACK, allow_public: undefined }],
['missing allow_marketing', { ...VALID_FEEDBACK, allow_marketing: undefined }],
['a boolean sent as a string', { ...VALID_FEEDBACK, allow_public: 'true' }],
['a title of only whitespace', { ...VALID_FEEDBACK, title: ' ' }],
['an unknown field', { ...VALID_FEEDBACK, ownerId: 7 }]
])('feedback rejects %s before anything is signed', async (_label, data) => {
const response = await request(app)
.post('/api/admin/usage/feedback')
.set('Authorization', `Bearer ${token('admin')}`)
.send(JSON.parse(JSON.stringify(data)))
.expect(400);
// Named, not a bare protocol failure the caller cannot act on.
expect(response.body.code).toBe('VALIDATION_ERROR');
expect(service.command).not.toHaveBeenCalled();
});
test('feedback accepts the complete payload and mints the id server-side', async () => {
await request(app)
.post('/api/admin/usage/feedback')
.set('Authorization', `Bearer ${token('admin')}`)
.send({ ...VALID_FEEDBACK, name: 'QA' })
.expect(200);
expect(service.command).toHaveBeenCalledWith(
'feedback',
expect.objectContaining({ name: 'QA', feedback_id: expect.any(String) })
);
});
// Runs last on purpose: the limiter's budget is per-process and shared with
// every test above that reaches an outbound route, so consuming it here cannot
// starve them. The assertion is deliberately about the property — some request
// is refused and the service stops being called — rather than an exact count,
// which would depend on how much budget earlier tests used.
test('the outbound routes are throttled so an admin session cannot flood the collector', async () => {
const codes = [];
for (let i = 0; i < 45; i += 1) {
const response = await request(app)
.post('/api/admin/usage/feedback')
.set('Authorization', `Bearer ${token('admin')}`)
.send({ ...VALID_FEEDBACK, title: `flood ${i}` });
codes.push(response.status);
if (response.status === 429) {
expect(response.body.code).toBe('USAGE_RATE_LIMITED');
break;
}
}
expect(codes).toContain(429);
expect(service.command.mock.calls.length).toBeLessThan(codes.length);
// The same budget covers the other two routes that relay to the collector.
await request(app)
.post('/api/admin/usage/vote')
.set('Authorization', `Bearer ${token('admin')}`)
.send({ feedback_id: '11111111-1111-4111-8111-111111111111', voted: true })
.expect(429);
await request(app)
.post('/api/admin/usage/portal-session')
.set('Authorization', `Bearer ${token('admin')}`)
.expect(429);
// Reading status and withdrawing must never be throttled: those are how an
// operator sees what is happening and how they get out.
await request(app)
.get('/api/admin/usage')
.set('Authorization', `Bearer ${token('admin')}`)
.expect(200);
await request(app)
.post('/api/admin/usage/disable')
.set('Authorization', `Bearer ${token('admin')}`)
.expect(200);
});
@@ -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;
@@ -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);
});
};
+66 -19
View File
@@ -1,5 +1,6 @@
const express = require('express');
const crypto = require('crypto');
const rateLimit = require('express-rate-limit');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { ValidationError } = require('../utils/errors');
@@ -21,6 +22,30 @@ const wrap = (fn) => (req, res, next) =>
.json({ error: 'Invalid usage request', code: error.code });
next(error);
});
// The three routes below are the only ones whose effect is an outbound
// request to someone else's service, carrying operator-written free text
// (title 120, body 4000, name 80). The platform's general limiter skips
// authenticated requests by design, which is right for endpoints that only
// touch this installation and wrong for a relay: without this an admin
// session can push unbounded traffic at the collector.
//
// Keyed to the installation, not the caller's IP, because the budget being
// protected is "how much this install relays", and per-process because that
// is the same store the rest of the app uses — a multi-replica deployment
// gets one budget per replica, which still bounds the shape that matters.
const outboundLimiter = rateLimit({
windowMs: 60 * 60 * 1000,
max: 30,
keyGenerator: () => 'usage-outbound',
standardHeaders: true,
legacyHeaders: false,
handler: (_req, res) =>
res.status(429).json({
error: 'Too many usage submissions. Try again later.',
code: 'USAGE_RATE_LIMITED'
})
});
router.use(adminAuth);
router.use((_req, res, next) => {
res.set('Cache-Control', 'no-store');
@@ -66,9 +91,17 @@ router.post(
'/disable',
wrap(async (_req, res) => res.json(await service.disable()))
);
// Reachable only from a withdrawal whose delete packet can never be signed;
// the service refuses in every other state. See UsageService.abandon().
router.post(
'/abandon',
wrap(async (_req, res) => res.json(await service.abandon()))
);
// An operator asking for a retry skips the delivery backoff — that button
// exists precisely to not wait for the next window.
router.post(
'/retry',
wrap(async (_req, res) => res.json(await service.tick()))
wrap(async (_req, res) => res.json(await service.tick({ force: true })))
);
router.get(
'/preview',
@@ -84,29 +117,41 @@ router.put(
'/feedback-preferences',
wrap(async (req, res) => res.json(await service.preferences(req.body)))
);
// Every field the packet schema requires. The allowlist used to let `name`,
// `allow_public` and `allow_marketing` be omitted, and the packet schema —
// which requires all of them — then failed with a bare INVALID_PACKET instead
// of naming the missing field. The UI always sends them; anything driving the
// API directly did not, and got an error it could not act on.
const FEEDBACK_FIELDS = [
'kind',
'title',
'body',
'name',
'allow_public',
'allow_marketing'
];
router.post(
'/feedback',
outboundLimiter,
wrap(async (req, res) => {
const body = req.body;
if (
!body ||
Object.keys(body).some(
(k) =>
![
'kind',
'title',
'body',
'name',
'allow_public',
'allow_marketing'
].includes(k)
) ||
typeof body.title !== 'string' ||
!body.title.trim() ||
typeof body.body !== 'string' ||
!body.body.trim()
)
if (!body || typeof body !== 'object')
throw new ValidationError('Invalid feedback');
const unknown = Object.keys(body).filter(
(key) => !FEEDBACK_FIELDS.includes(key)
);
if (unknown.length)
throw new ValidationError(
`Unknown feedback fields: ${unknown.join(', ')}`
);
for (const key of ['kind', 'title', 'body', 'name'])
if (typeof body[key] !== 'string')
throw new ValidationError(`Feedback field "${key}" must be a string`);
for (const key of ['allow_public', 'allow_marketing'])
if (typeof body[key] !== 'boolean')
throw new ValidationError(`Feedback field "${key}" must be a boolean`);
if (!body.title.trim() || !body.body.trim())
throw new ValidationError('Feedback title and body are required');
res.json(
await service.command('feedback', {
...body,
@@ -117,10 +162,12 @@ router.post(
);
router.post(
'/vote',
outboundLimiter,
wrap(async (req, res) => res.json(await service.command('vote', req.body)))
);
router.post(
'/portal-session',
outboundLimiter,
wrap(async (_req, res) => {
const result = await service.command('session', {});
res.json({
+154 -9
View File
@@ -248,6 +248,20 @@ class UsageService {
consent_update_available: state.status === 'active' && this.schemaVersion(state) !== CURRENT_SCHEMA_VERSION,
last_report_date: state.last_report_date,
last_error: state.last_error,
// Epoch ms, or null when nothing is being paced. The settings page shows
// it so a waiting install reads as "waiting" rather than as broken.
retry_after:
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',
pending_action: state.pending_packet
? JSON.parse(state.pending_packet).action
: null,
@@ -278,6 +292,29 @@ class UsageService {
}
}
// Consecutive failures pace the unattended sender: 2, 4, 8, 16, 32 minutes,
// then hourly. Capped rather than unbounded because a collector that comes
// back after a long outage should be noticed within the hour, and a report
// is only due once per UTC day anyway.
backoffMs(attempts) {
return Math.min(2 ** Math.max(1, attempts), 60) * 60000;
}
async noteDeliveryFailure() {
const state = await this.state();
const attempts = Number(state?.attempts || 0) + 1;
await this.db('product_usage_state')
.where({ id: 1 })
.update({
attempts,
next_attempt_at: this.now() + this.backoffMs(attempts)
});
}
async clearDeliveryBackoff() {
await this.db('product_usage_state')
.where({ id: 1 })
.update({ attempts: 0, next_attempt_at: 0 });
}
async dismiss() {
await this.db('product_usage_state')
.where({ id: 1 })
@@ -329,7 +366,9 @@ class UsageService {
instance_binding: instanceBinding,
sequence: 0,
pending_packet: JSON.stringify(pending),
last_error: null
last_error: null,
attempts: 0,
next_attempt_at: 0
});
// Withdrawn while activating. Participation stays off and nothing was
// registered, so there is nothing to delete remotely either.
@@ -363,11 +402,13 @@ class UsageService {
pending_packet: null,
last_packet: null,
last_receipt: null,
last_report_date: null
last_report_date: null,
attempts: 0,
next_attempt_at: 0
});
await this.db('product_usage_markers').delete();
try {
await this.tick();
await this.tick({ force: true });
} catch (error) {
// A sender may still own the lease. Collection is already stopped and
// the next admin activity retries deletion after that sender finishes.
@@ -376,6 +417,71 @@ class UsageService {
return this.status();
}
// The escape hatch for a withdrawal that can never be signed. When
// USAGE_ENCRYPTION_KEY — or the JWT_SECRET it falls back to — has been
// rotated, the private key is unreadable, so the delete packet cannot be
// produced at all. Retrying and disabling both no-op forever, and enable()
// refuses because the row is not `disabled`: the feature is bricked with no
// control left. Restoring the old key material is the correct fix and stays
// the documented one, but an operator who rotated because of a suspected
// compromise no longer has it.
//
// This drops the local identity and says so honestly: collection is already
// stopped, but the collector was never told, so the receipt records
// `collector-unconfirmed` rather than claiming a deletion that did not
// happen. Deliberately not folded into enable() — abandoning an
// 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'
)
throw new ConflictError(
'Only an unsignable withdrawal can be abandoned'
);
await fs.unlink(this.bindingPath).catch((error) => {
if (error.code !== 'ENOENT') throw error;
});
await this.db('product_usage_markers').delete();
const receipts = state.privacy_receipts
? JSON.parse(state.privacy_receipts)
: {};
await this.db('product_usage_state')
.where({ id: 1, status: 'deletion_pending' })
.update({
status: 'disabled',
installation_id: null,
public_key: null,
private_key_encrypted: null,
instance_binding: null,
pending_packet: null,
last_packet: null,
last_receipt: null,
last_report_date: null,
last_error: null,
sequence: 0,
attempts: 0,
next_attempt_at: 0,
feedback_preferences: null,
privacy_receipts: JSON.stringify({
...receipts,
last_abandonment: {
receipt_version: 'local-audit.v1',
kind: 'abandonment',
receipt_id: crypto.randomUUID(),
confirmed_at: new Date(this.now()).toISOString(),
status: 'collector-unconfirmed',
reason: 'SIGNING_KEY_UNREADABLE',
installation_id: state.installation_id,
scope: ['local identity', 'local markers', 'local key material']
}
})
});
});
return this.status();
}
async post(pathname, body, maxResponseBytes = 65536) {
const response = await this.fetch(`${this.collectorUrl()}${pathname}`, {
method: 'POST',
@@ -496,6 +602,8 @@ class UsageService {
last_report_date: null,
last_error: null,
sequence: 0,
attempts: 0,
next_attempt_at: 0,
feedback_preferences: null
});
} else {
@@ -505,6 +613,8 @@ class UsageService {
sequence: packet.sequence,
pending_packet: null,
last_error: null,
attempts: 0,
next_attempt_at: 0,
last_receipt: JSON.stringify(storedReceipt)
};
if (packet.action === 'report') {
@@ -556,7 +666,12 @@ class UsageService {
await this.db('product_usage_state')
.where({ id: 1 })
.whereNot({ status: 'deletion_pending' })
.update({ pending_packet: null, last_error: 'REQUEST_REJECTED' });
.update({
pending_packet: null,
last_error: 'REQUEST_REJECTED',
attempts: 0,
next_attempt_at: 0
});
return null;
}
const conflict = [
@@ -574,6 +689,11 @@ class UsageService {
await this.db('product_usage_state')
.where({ id: 1 })
.update({ last_error: code });
// Paced, not abandoned: the packet stays pending and the operator can
// still force a retry from the settings page. Only the automatic sender
// waits, which is what stops one permanently rejected packet from
// producing one collector request per admin click.
await this.noteDeliveryFailure();
if (conflict && packet.action !== 'delete') {
await this.db('product_usage_state')
.where({ id: 1 })
@@ -584,9 +704,15 @@ class UsageService {
}
}
async tick() {
// `force` is what the Retry button and /disable pass: an operator asking for
// an attempt now must not be held behind a backoff they can see and want to
// skip. The unattended callers — /activity and the settings ticker — leave
// it off, so a failing packet costs one request per backoff window instead
// of one per admin action.
async tick({ force = false } = {}) {
await this.locked(async (state) => {
if (state.status === 'disabled') return;
if (!force && Number(state.next_attempt_at || 0) > this.now()) return;
if (state.status === 'deletion_pending') {
const packet = makePacket(state, 'delete', Number(state.sequence), {}, this.schemaVersion(state));
state.pending_packet = JSON.stringify(packet);
@@ -666,7 +792,13 @@ class UsageService {
});
}
async snapshot(version) {
// `persist` is false for the settings preview. snapshot() records applied
// custom CSS as a lifetime marker, which meant the "see exactly what would
// be sent" view changed what gets sent — a read with a write behind it, in
// the one place whose whole job is transparency. The reported value is
// unaffected: the marker is derived here either way, and the next real
// report persists it.
async snapshot(version, { persist = true } = {}) {
version = version || this.schemaVersion(await this.state());
const rows = await this.db('app_settings')
.whereIn('setting_key', SETTING_KEYS)
@@ -777,7 +909,7 @@ class UsageService {
// Applied CSS is already a capability in use; no visitor observation is
// needed. Remember its presence as a coarse lifetime marker after consent.
if (features.custom_css.configured) {
await this.markUsed(['custom_css']);
if (persist) await this.markUsed(['custom_css']);
features.custom_css.used = true;
}
const now = new Date(this.now()).toISOString();
@@ -797,7 +929,7 @@ class UsageService {
const state = await this.state();
if (state.status !== 'active')
throw new ConflictError('Usage participation is not active');
return this.snapshot();
return this.snapshot(null, { persist: false });
}
async command(action, payload) {
let receipt;
@@ -894,10 +1026,23 @@ class UsageService {
kind: 'export',
receipt_id: crypto.randomUUID(),
confirmed_at: new Date(this.now()).toISOString(),
// Reports only. Counting every packet — feedback, votes, portal
// sessions, the registration — and labelling the total "usage
// reports" made a privacy receipt state something untrue about
// its own contents, which is exactly the document that has to be
// exact. `packet_count` keeps the total available alongside it.
report_count: Array.isArray(result.packets)
? result.packets.filter(
(envelope) => envelope?.packet?.action === 'report'
).length
: 0,
packet_count: Array.isArray(result.packets)
? result.packets.length
: 0,
scope: ['unique accepted usage reports']
scope: [
'accepted usage reports',
'accepted participant operations'
]
}
})
});
+16 -4
View File
@@ -91,12 +91,24 @@ async function expandSnapshot(db, { features, flags, used, now }) {
result.gallery_expiration.configured = await exists('events', ['expires_at'], (query) => query.whereNotNull('expires_at'));
result.download_resolution_picker.configured = truth(settings.download_resolution_picker_enabled) ||
await enabled('events', 'download_resolution_picker_enabled');
result.gallery_image_protection.configured = ['standard', 'enhanced', 'maximum'].includes(settings.default_protection_level) ||
truth(settings.enable_devtools_protection) || truth(settings.enable_canvas_rendering);
for (const column of ['disable_right_click', 'enable_devtools_protection', 'use_canvas_rendering'])
// Only what an operator actually changed. PicPeak ships
// default_protection_level='standard' and enable_devtools_protection=true —
// globally and on every event row — so accepting either as evidence made
// this signal `true` on a bare install with no galleries at all. It reported
// fleet-wide 100% and could never separate a deliberate configuration from
// an untouched one, which is a field that costs consent budget and explains
// nothing. `enable_devtools_protection` is therefore not read at all: being
// on by default, its only informative state is off, which is the opposite
// of what this key claims. The remaining inputs each ship off ('standard'
// protection, no canvas rendering, right-click allowed), so a true here is
// always a decision someone made.
result.gallery_image_protection.configured =
['enhanced', 'maximum'].includes(settings.default_protection_level) ||
truth(settings.enable_canvas_rendering);
for (const column of ['disable_right_click', 'use_canvas_rendering'])
result.gallery_image_protection.configured ||= await enabled('events', column);
result.gallery_image_protection.configured ||= await exists('events', ['protection_level'], (query) =>
query.whereIn('protection_level', ['standard', 'enhanced', 'maximum']));
query.whereIn('protection_level', ['enhanced', 'maximum']));
for (const [suffix, column] of Object.entries({
likes: 'allow_likes', ratings: 'allow_ratings', comments: 'allow_comments',
favorites: 'allow_favorites', reactions: 'allow_reactions', color_labels: 'allow_color_labels'
+2 -2
View File
@@ -1252,8 +1252,8 @@
"de": "Bildschutz aktiviert"
},
"configured": {
"en": "Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts.",
"de": "In der betreffenden Galerie-/Globalkonfiguration aktiviert; nur installationsweite Existenz, niemals Galeriekennungen oder Anzahlen."
"en": "Enabled beyond the shipped defaults — a stronger protection level, canvas rendering, or right-click disabled — globally or on at least one gallery; only existence across the installation, never gallery IDs or counts.",
"de": "Über die Auslieferungsvorgaben hinaus aktiviert — höhere Schutzstufe, Canvas-Rendering oder deaktivierter Rechtsklick — global oder in mindestens einer Galerie; nur installationsweite Existenz, niemals Galeriekennungen oder Anzahlen."
},
"used": null
},
+1 -1
View File
@@ -133,7 +133,7 @@ Legacy v1 semantics remain documented separately in the protocol reference.
| `download_resolution_picker` — Download resolution picker enabled / Download-Auflösungswahl aktiviert | usage.v2 | Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts. | **Not collected. Configuration only.** |
| `gallery_client_access` — Client access enabled / Client-Zugang aktiviert | usage.v2 | Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts. | **Not collected. Configuration only.** |
| `gallery_watermarks` — Watermarks enabled / Wasserzeichen aktiviert | usage.v2 | Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts. | **Not collected. Configuration only.** |
| `gallery_image_protection` — Image protection enabled / Bildschutz aktiviert | usage.v2 | Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts. | **Not collected. Configuration only.** |
| `gallery_image_protection` — Image protection enabled / Bildschutz aktiviert | usage.v2 | Enabled beyond the shipped defaults — a stronger protection level, canvas rendering, or right-click disabled — globally or on at least one gallery; only existence across the installation, never gallery IDs or counts. | **Not collected. Configuration only.** |
| `gallery_reveal` — Gallery reveal enabled / Galerie-Enthüllung aktiviert | usage.v2 | Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts. | **Not collected. Configuration only.** |
| `gallery_expiration` — Gallery expiration configured / Galerieablauf konfiguriert | usage.v2 | At least one gallery has an expiry configured; no dates, gallery IDs or counts. | **Not collected. Configuration only.** |
+33 -4
View File
@@ -45,7 +45,15 @@ defaults to `JWT_SECRET`, so rotating `JWT_SECRET` without setting a dedicated
`USAGE_ENCRYPTION_KEY` first loses it. The settings page then reports
`SIGNING_KEY_UNREADABLE` rather than a generic delivery failure, because the
consequence is specific: reports stop and the deletion request can no longer
be signed either. Keys live in a dedicated database
be signed either. Restoring the original key material is the correct fix and
completes the pending deletion. When it is genuinely gone — a rotation done
because the secret was compromised — the settings page offers **Discard local
identity** (`POST /api/admin/usage/abandon`), which is available in no other
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
table, not the generic readable settings. A random mode-0600 file at
`getStoragePath()/usage-instance.key` binds the database to its local storage.
@@ -75,6 +83,16 @@ durable and retried. Multiple admin tabs/processes share a database lease;
only accepted receipts advance the sequence and report date. Re-signed retries
reuse the immutable packet ID so lost acknowledgements do not duplicate data.
Retries are paced (migration 206). Consecutive failures set `attempts` and
`next_attempt_at`, and the unattended sender — the activity endpoint and the
settings ticker — waits for that gate: 2, 4, 8, 16, 32 minutes, then hourly.
Without it a packet the collector rejects permanently produced one collector
request per admin action, because any authenticated admin reaches the activity
endpoint and every open admin tab fires it every five minutes. Explicit
operator actions are not paced: **Retry** and opt-out send immediately, and the
settings page names the time of the next automatic attempt so a waiting
installation does not read as a broken one.
Opt-out immediately stops collection, clears markers/previews/feedback
preferences, and enters deletion pending. It keeps only credentials and the
deletion operation until the collector confirms deletion. The collector removes
@@ -84,9 +102,12 @@ a fresh identity. Repeated deletion handles lost receipts safely.
Migration 204 adds bounded, local-only privacy receipts and removes any legacy
plaintext voting token from the last collector receipt. A completed export
records its time and report count; confirmed opt-out replaces this with a
deletion receipt containing only a random receipt ID, time, status and fixed
scope. It retains no old installation hash, key, payload or credential. The
records its time, the number of accepted reports and the total number of
accepted packets separately — feedback, votes and portal sessions are
participant operations, not reports, and a receipt that folded them into one
"reports" figure stated something untrue about its own contents. Confirmed
opt-out replaces this with a deletion receipt containing only a random receipt
ID, time, status and fixed scope. It retains no old installation hash, key, payload or credential. The
settings page can download these receipts even after opt-out. They are local
records of the collector acknowledgement, not independent proof of storage
erasure. Downloaded exports carry their own dated receipt; the collector does
@@ -105,6 +126,14 @@ feedback preferences. Any authenticated admin may trigger the fixed daily
report; the activity endpoint accepts no telemetry input. Every usage endpoint
uses adminAuth, including token-type checks. Gallery tokens cannot use it.
Feedback, votes and portal sessions share one installation-wide budget of 30
per hour. They are the only endpoints whose effect is an outbound request
carrying operator-written free text, and the platform's general limiter skips
authenticated requests by design — correct for endpoints that touch only this
installation, wrong for a relay. Reading status, retrying and opting out are
never throttled: those are how an operator sees what is happening and how they
leave.
Feedback is sent only on explicit submission. Each item defaults anonymous and
private; names, publication permission, and testimonial marketing permission
are separate choices. Published requests/testimonials require maintainer review.
+2 -1
View File
@@ -1218,7 +1218,7 @@
"adminUsage.js": {
"decision": "excluded",
"signals": [],
"reason": "Consent, inspection, export, feedback, voting and deletion are explicit protocol operations; not product-use signals. Activity only triggers a due fixed report.",
"reason": "Consent, inspection, export, feedback, voting, deletion and abandoning an unsignable deletion are explicit protocol operations; not product-use signals. Activity only triggers a due fixed report.",
"route_signatures": [
"POST /activity",
"GET /",
@@ -1226,6 +1226,7 @@
"POST /enable",
"POST /consent",
"POST /disable",
"POST /abandon",
"POST /retry",
"GET /preview",
"GET /export",
@@ -31,6 +31,7 @@ vi.mock('../../../services/productUsage.service', () => ({
upgradeConsent: vi.fn(),
disable: vi.fn(),
retry: vi.fn(),
abandon: vi.fn(),
preview: vi.fn(),
export: vi.fn(),
preferences: vi.fn(),
@@ -216,3 +217,90 @@ describe('product usage controls', () => {
await waitFor(() => expect(service.retry).toHaveBeenCalled());
});
});
describe('a withdrawal that can never be signed', () => {
const stuck: UsageStatus = {
...status,
status: 'deletion_pending',
installation_id: 'a'.repeat(64),
schema_version: 'usage.v2',
last_error: 'SIGNING_KEY_UNREADABLE',
can_abandon: true
};
it('explains the dead end and offers the only remaining exit', async () => {
vi.mocked(service.status).mockResolvedValue(stuck);
vi.mocked(service.abandon).mockResolvedValue({ ...status });
mount();
// The operator is told what happened before being offered the exit.
await screen.findByText('productUsage.signingKeyUnreadable');
await screen.findByText('productUsage.abandonExplanation');
fireEvent.click(await screen.findByText('productUsage.abandon'));
await waitFor(() => expect(service.abandon).toHaveBeenCalledTimes(1));
});
it('does not offer it for a withdrawal that is merely undelivered', async () => {
vi.mocked(service.status).mockResolvedValue({
...stuck,
last_error: 'DELIVERY_FAILED',
can_abandon: false
});
mount();
await screen.findByText('productUsage.deliveryProblem');
expect(screen.queryByText('productUsage.abandon')).toBeNull();
});
});
it('says the sender is waiting rather than leaving a bare error on screen', async () => {
vi.mocked(service.status).mockResolvedValue({
...status,
status: 'active',
schema_version: 'usage.v2',
installation_id: 'a'.repeat(64),
last_error: 'DELIVERY_FAILED',
retry_after: Date.now() + 600000
});
mount();
await screen.findByText('productUsage.retryScheduled');
});
it('marks a deletion receipt as belonging to an earlier participation', async () => {
const receipts = { last_deletion: { kind: 'deletion' } };
vi.mocked(service.status).mockResolvedValue({
...status,
status: 'active',
schema_version: 'usage.v2',
installation_id: 'a'.repeat(64),
privacy_receipts: receipts
});
mount();
await screen.findByText('productUsage.auditPreviousParticipation');
cleanup();
// Withdrawn: the same receipt now describes the participation just ended,
// so the qualifier would be wrong.
vi.mocked(service.status).mockResolvedValue({ ...status, privacy_receipts: receipts });
mount();
await screen.findByText('productUsage.auditTitle');
expect(screen.queryByText('productUsage.auditPreviousParticipation')).toBeNull();
});
it('returns focus to the control that opened the consent dialog', async () => {
mount();
const trigger = await screen.findByText('productUsage.review');
trigger.focus();
expect(document.activeElement).toBe(trigger);
fireEvent.click(trigger);
await screen.findByText('productUsage.consentTitle');
fireEvent.click(screen.getByText('productUsage.cancel'));
// Without the restore this lands on <body>, dropping a keyboard user back
// to the top of the page (WCAG 2.4.3).
await waitFor(() =>
expect(document.activeElement).toBe(
screen.getByText('productUsage.review')
)
);
});
@@ -36,6 +36,13 @@ const DISCLOSURE: {
{ key: 'feedbackDisclosure', heading: 'sectionFeedback', Icon: MessageSquare }
];
// `.btn` is whitespace-nowrap and `.btn-md` a fixed 2.5rem tall — right for
// short labels, wrong for the sentence-length ones in this tab, which ran off
// the card at 390px and then, once allowed to wrap, out of the fixed height.
// h-auto lets the second line have somewhere to go; min-h keeps a one-line
// button the same size as every other button beside it.
const WRAPPING_BUTTON = 'max-w-full whitespace-normal text-left h-auto min-h-[2.5rem]';
function ConsentDialog({
close,
enable,
@@ -53,6 +60,12 @@ function ConsentDialog({
const ref = useRef<HTMLDialogElement>(null);
const [checked, setChecked] = useState(false);
useEffect(() => {
// React unmounts this <dialog> on close rather than only closing it, so
// the focus restoration showModal() normally performs has nothing left to
// return to and focus drops to <body> — a keyboard user is thrown back to
// the top of the page every time they cancel (WCAG 2.4.3). Remember the
// opener and put focus back by hand.
const opener = document.activeElement as HTMLElement | null;
ref.current?.showModal();
// showModal() focuses the first focusable descendant, which is the scroll
// region below — so its focus ring was drawn for everyone the moment the
@@ -61,6 +74,9 @@ function ConsentDialog({
// Focusing the dialog puts the ring back where it belongs: only when
// someone deliberately tabs to the region.
ref.current?.focus();
return () => {
if (opener?.isConnected) opener.focus();
};
}, []);
return (
<dialog
@@ -261,6 +277,46 @@ export default function ProductUsageTab() {
)}
</p>
)}
{data.retry_after && (
// A paced install is waiting, not broken. Without this the tab shows
// a delivery error and an idle Retry button, and nothing says the
// sender is going to try again on its own.
<p role="status" className="text-sm text-neutral-600 dark:text-neutral-400">
{t('productUsage.retryScheduled', {
time: new Date(data.retry_after).toLocaleTimeString()
})}
</p>
)}
{data.can_abandon && (
// 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>
<Button
variant="outline"
className={WRAPPING_BUTTON}
disabled={busy}
onClick={async () => {
if (
await confirm({
title: t('productUsage.abandon'),
message: t('productUsage.abandonConfirm'),
confirmLabel: t('productUsage.abandon'),
variant: 'danger'
})
) {
await run(async () => {
await service.abandon();
setPreview(null);
setPortalUrl(null);
});
}
}}
>
{t('productUsage.abandon')}
</Button>
</div>
)}
<div className="flex flex-wrap gap-3">
{data.status === 'disabled' ? (
<Button disabled={busy} onClick={() => setConsent(true)}>
@@ -323,6 +379,19 @@ export default function ProductUsageTab() {
{t('productUsage.auditTitle')}
</h3>
<p>{t('productUsage.auditDescription')}</p>
{/* The receipts outlive the participation they describe: rejoining
does not clear them, so an active install would otherwise show
a bare "deletion confirmed" next to its own live participation
and read as a contradiction. */}
{active &&
Boolean(
data.privacy_receipts.last_deletion ||
data.privacy_receipts.last_abandonment
) && (
<p className="text-sm text-neutral-600 dark:text-neutral-400">
{t('productUsage.auditPreviousParticipation')}
</p>
)}
<Button
variant="outline"
onClick={() =>
@@ -342,9 +411,14 @@ export default function ProductUsageTab() {
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
{t('productUsage.inspect')}
</h3>
{/* `.btn` sets whitespace-nowrap, and these labels are long
sentences in both locales — at 390px two of them ran past the
card and their text was simply cut off. Allowed to wrap and
capped at the container width instead. */}
<div className="flex flex-wrap gap-3">
<Button
variant="outline"
className={WRAPPING_BUTTON}
disabled={busy}
onClick={() =>
run(async () => setPreview(await service.preview()))
@@ -354,6 +428,7 @@ export default function ProductUsageTab() {
</Button>
<Button
variant="outline"
className={WRAPPING_BUTTON}
disabled={busy || !data.last_packet}
onClick={() => setPreview(data.last_packet)}
>
@@ -361,6 +436,7 @@ export default function ProductUsageTab() {
</Button>
<Button
variant="outline"
className={WRAPPING_BUTTON}
disabled={busy}
onClick={() =>
run(async () => download(await service.export()))
@@ -370,6 +446,7 @@ export default function ProductUsageTab() {
</Button>
<Button
variant="outline"
className={WRAPPING_BUTTON}
disabled={busy || Boolean(data.pending_action)}
onClick={() =>
run(async () => {
@@ -1252,8 +1252,8 @@
"de": "Bildschutz aktiviert"
},
"configured": {
"en": "Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts.",
"de": "In der betreffenden Galerie-/Globalkonfiguration aktiviert; nur installationsweite Existenz, niemals Galeriekennungen oder Anzahlen."
"en": "Enabled beyond the shipped defaults — a stronger protection level, canvas rendering, or right-click disabled — globally or on at least one gallery; only existence across the installation, never gallery IDs or counts.",
"de": "Über die Auslieferungsvorgaben hinaus aktiviert — höhere Schutzstufe, Canvas-Rendering oder deaktivierter Rechtsklick — global oder in mindestens einer Galerie; nur installationsweite Existenz, niemals Galeriekennungen oder Anzahlen."
},
"used": null
},
+22 -17
View File
@@ -12,8 +12,8 @@
"currentSchema": "Aktuelles Berichtsschema: {{schema}}",
"reviewUpgrade": "Erweiterten Umfang von usage.v2 prüfen",
"upgrade": "usage.v2 ausdrücklich zustimmen",
"upgradeExplanation": "Deine bestehende usage.v1-Teilnahme bleibt unverändert. Prüfe den vollständigen erweiterten Katalog, bevor du über das Upgrade entscheidest. Eine Ablehnung beendet deine bisherige Teilnahme nicht.",
"upgradePending": "Die signierte Erweiterung der Zustimmung wartet auf Bestätigung. Es wird nur der bisherige v1-Umfang erfasst. Versuche es erneut, sobald der Collector erreichbar ist, oder deaktiviere die Teilnahme zum Stoppen und Löschen.",
"upgradeExplanation": "Ihre bestehende usage.v1-Teilnahme bleibt unverändert. Prüfen Sie den vollständigen erweiterten Katalog, bevor Sie über das Upgrade entscheiden. Eine Ablehnung beendet Ihre bisherige Teilnahme nicht.",
"upgradePending": "Die signierte Erweiterung der Zustimmung wartet auf Bestätigung. Es wird nur der bisherige v1-Umfang erfasst. Versuchen Sie es erneut, sobald der Collector erreichbar ist, oder deaktivieren Sie die Teilnahme zum Stoppen und Löschen.",
"catalog": {
"crm": {
"name": "Kundenverwaltung",
@@ -353,7 +353,7 @@
},
"gallery_image_protection": {
"name": "Bildschutz aktiviert",
"configured": "In der betreffenden Galerie-/Globalkonfiguration aktiviert; nur installationsweite Existenz, niemals Galeriekennungen oder Anzahlen."
"configured": "Über die Auslieferungsvorgaben hinaus aktiviert — höhere Schutzstufe, Canvas-Rendering oder deaktivierter Rechtsklick — global oder in mindestens einer Galerie; nur installationsweite Existenz, niemals Galeriekennungen oder Anzahlen."
},
"gallery_reveal": {
"name": "Galerie-Enthüllung aktiviert",
@@ -365,17 +365,17 @@
}
},
"auditTitle": "Export- und Löschquittungen",
"auditDescription": "Lade deine privaten Nachweise herunter. PicPeak speichert nur die letzte Exportquittung während der Teilnahme und die letzte Löschbestätigung. Sie enthalten keinen Installationshash, Schlüssel oder Bericht-/Feedbackinhalt. Opt-out entfernt die lokale Exportquittung; die Löschbestätigung ohne Identitätsbezug bleibt erhalten. Der Collector führt keinen Export- oder Zugriffsverlauf.",
"auditDescription": "Laden Sie Ihre privaten Nachweise herunter. PicPeak speichert nur die letzte Exportquittung während der Teilnahme und die letzte Löschbestätigung. Sie enthalten keinen Installationshash, Schlüssel oder Bericht-/Feedbackinhalt. Opt-out entfernt die lokale Exportquittung; die Löschbestätigung ohne Identitätsbezug bleibt erhalten. Der Collector führt keinen Export- oder Zugriffsverlauf.",
"auditDownload": "Datenschutzquittungen herunterladen",
"title": "Produktnutzung & Feedback",
"noticeTitle": "Gestalten Sie PicPeak mit",
"notice": "Optionale Nutzungsberichte zeigen, welche Funktionen für die Community wichtig sind. Die Übermittlung ist aus, bis Sie sich aktiv dafür entscheiden.",
"ignore": "Ignorieren",
"ignoreHint": "Dieser Hinweis erscheint nicht erneut — Sie können weiterhin unter Einstellungen → Produktnutzung teilnehmen.",
"ignoreHint": "Wenn Sie ignorieren, erscheint dieser Hinweis nicht erneut — Sie können weiterhin unter Einstellungen → Produktnutzung teilnehmen.",
"review": "Teilnahme prüfen",
"cancel": "Abbrechen",
"loading": "Teilnahmeeinstellungen werden geladen…",
"failed": "Der Vorgang konnte nicht abgeschlossen werden. Prüfe den Status und versuche es erneut.",
"failed": "Der Vorgang konnte nicht abgeschlossen werden. Prüfen Sie den Status und versuchen Sie es erneut.",
"purpose": "Hilf bei der Priorisierung von PicPeak-Funktionen, Fehlerbehebungen und Wartung mit groben Informationen über teilnehmende Installationen.",
"consentTitle": "Produktnutzung freiwillig teilen",
"sectionFields": "Was ein Bericht enthält",
@@ -385,19 +385,19 @@
"sectionDeletion": "Beenden und löschen",
"sectionFeedback": "Feedback ist getrennt",
"excluded": "Automatische Berichte enthalten keine Galeriebesucher, Klickverläufe, Foto- oder Galerieanzahlen, Namen, E-Mail-Adressen, Domains, Dateinamen oder Zugangsdaten.",
"transport": "Dein PicPeak-Backend verwahrt den Signaturschlüssel und sendet einmal pro UTC-Tag bei Admin-Nutzung signierte Nutzungsberichte an {{collector}}. Du kannst Berichte vorab ansehen und jeden eindeutig angenommenen Bericht genau wie beim ersten Empfang herunterladen; Übertragungswiederholungen werden zusammengeführt. Abgelehnte Versuche und getrennt gesendetes Feedback gehören nicht zu diesem Berichtsexport.",
"visibility": "Nur teilnehmende Installationen können den Funktionsdatensatz und aggregierte Ergebnisse einsehen, auch Gruppen mit nur einer Installation. Schema und Quellcode sind öffentlich; geprüfte Funktionswünsche und Empfehlungen werden nur mit Erlaubnis ihrer Verfasser veröffentlicht. Dein Fingerabdruck ist pseudonym, nicht anonym. Bewahre deinen Abfrage-Hash vertraulich auf: Er ermöglicht lesenden Zugriff auf deine eigenen Berichte und den Teilnehmerdatensatz.",
"transport": "Ihr PicPeak-Backend verwahrt den Signaturschlüssel und sendet einmal pro UTC-Tag bei Admin-Nutzung signierte Nutzungsberichte an {{collector}}. Sie können Berichte vorab ansehen und jeden eindeutig angenommenen Bericht genau wie beim ersten Empfang herunterladen; Übertragungswiederholungen werden zusammengeführt. Abgelehnte Versuche und getrennt gesendetes Feedback gehören nicht zu diesem Berichtsexport.",
"visibility": "Nur teilnehmende Installationen können den Funktionsdatensatz und aggregierte Ergebnisse einsehen, auch Gruppen mit nur einer Installation. Schema und Quellcode sind öffentlich; geprüfte Funktionswünsche und Empfehlungen werden nur mit Erlaubnis ihrer Verfasser veröffentlicht. Ihr Fingerabdruck ist pseudonym, nicht anonym. Bewahren Sie Ihren Abfrage-Hash vertraulich auf: Er ermöglicht lesenden Zugriff auf Ihre eigenen Berichte und den Teilnehmerdatensatz.",
"deletion": "Deaktivieren stoppt die Erfassung sofort und fordert die Löschung der Berichte, Aggregatbeiträge, Rückmeldungen, Veröffentlichungen, Stimmen und Sitzungen an. Bei einem Ausfall bleiben nur die zur Löschung nötigen Zugangsdaten erhalten; die Oberfläche zeigt die ausstehende Löschung. Nach Bestätigung werden Hash und Schlüssel lokal gelöscht; eine erneute Teilnahme erzeugt eine neue Identität. Der Collector behält einen Einweg-Sperrwert und kurzlebige Missbrauchszähler ohne Installationsbezug. PicPeak speichert eine herunterladbare lokale Löschquittung ohne den alten Hash, Schlüssel oder Inhalte.",
"feedbackDisclosure": "Feedback wird getrennt von automatischen Berichten und nur beim Absenden übertragen. Jeder Beitrag ist anonym, sofern du keinen Namen angibst, und nur für Betreuer sichtbar, sofern du die Veröffentlichung nicht ausdrücklich erlaubst. Öffentliche Beiträge werden geprüft. Die Verwendung einer Empfehlung für Marketing benötigt eine zusätzliche Erlaubnis.",
"feedbackDisclosure": "Feedback wird getrennt von automatischen Berichten und nur beim Absenden übertragen. Jeder Beitrag ist anonym, sofern Sie keinen Namen angeben, und nur für Betreuer sichtbar, sofern Sie die Veröffentlichung nicht ausdrücklich erlauben. Öffentliche Beiträge werden geprüft. Die Verwendung einer Empfehlung für Marketing benötigt eine zusätzliche Erlaubnis.",
"consentCheck": "Ich habe diese Hinweise gelesen und stimme der Teilnahme ausdrücklich zu.",
"enable": "Produktnutzung aktivieren",
"disable": "Deaktivieren & Daten löschen",
"retry": "Erneut versuchen / fälligen Bericht senden",
"transparency": "Öffentliches Schema & Datenschutzhinweise",
"linkCollector": "Wohin Berichte gesendet werden",
"hash": "Dein vertraulicher Abfrage-Hash",
"hash": "Ihr vertraulicher Abfrage-Hash",
"lastReport": "Zuletzt angenommener Bericht: {{date}} (UTC)",
"deliveryProblem": "Die Übertragung benötigt Aufmerksamkeit. Bei Löschung oder Identitätskonflikt ist die Erfassung gestoppt. Versuche es erneut oder deaktiviere die Teilnahme, um die Daten zu löschen.",
"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.",
"inspect": "Genau sehen, was geteilt wird",
@@ -410,7 +410,7 @@
"feedbackTitle": "Feedback & Funktionswünsche",
"kind": "Art",
"subject": "Titel",
"message": "Deine Nachricht",
"message": "Ihre Nachricht",
"includeName": "Diesem Beitrag einen Namen hinzufügen",
"name": "Anzeigename",
"saveName": "Diesen Namen lokal merken",
@@ -418,26 +418,31 @@
"allowPublic": "Ich erlaube die Veröffentlichung dieses Textes und des angegebenen Namens im Nutzungsportal nach Prüfung.",
"allowMarketing": "Ich erlaube zusätzlich die Verwendung dieser Empfehlung und des angegebenen Namens für Marketing auf der PicPeak-Homepage.",
"sendFeedback": "Feedback absenden",
"feedbackSent": "Feedback erhalten. Eine Veröffentlichung erfordert deine Erlaubnis und die Prüfung durch Betreuer.",
"feedbackSent": "Feedback erhalten. Eine Veröffentlichung erfordert Ihre Erlaubnis und die Prüfung durch Betreuer.",
"states": {
"disabled": "Teilnahme ist deaktiviert",
"activation_pending": "Aktivierung ausstehend",
"active": "Du nimmst teil",
"active": "Sie nehmen teil",
"deletion_pending": "Löschung ausstehend",
"identity_conflict": "Konflikt der Installationsidentität"
},
"stateDetails": {
"disabled": "Es werden keine Produktnutzungsdaten erfasst oder gesendet. Prüfe die Hinweise, bevor du dich entscheidest.",
"disabled": "Es werden keine Produktnutzungsdaten erfasst oder gesendet. Prüfen Sie die Hinweise, bevor Sie sich entscheiden.",
"activation_pending": "Die Zustimmung ist gespeichert. Die Registrierung wird bei Admin-Nutzung oder über „Erneut versuchen“ wiederholt.",
"active": "Nur die beschriebenen Funktionssignale werden erfasst. Tagesberichte werden bei Admin-Nutzung gesendet.",
"deletion_pending": "Erfassung und Berichte sind gestoppt. Die Signaturdaten bleiben ausschließlich für die bestätigte Löschung erhalten. Versuche es erneut, sobald der Dienst erreichbar ist.",
"deletion_pending": "Erfassung und Berichte sind gestoppt. Die Signaturdaten bleiben ausschließlich für die bestätigte Löschung erhalten. Versuchen Sie es erneut, sobald der Dienst erreichbar ist.",
"identity_conflict": "Möglicherweise wurde diese Installation wiederhergestellt oder kopiert, oder die Berichtsfolge stimmt nicht mehr mit dem Dienst überein. Berichte sind gestoppt. Deaktiviere und lösche die alte Teilnahme vor einem erneuten Beitritt mit neuer Identität. Dadurch werden auch die Daten einer weiteren Kopie derselben Identität gelöscht."
},
"kinds": {
"feedback": "Privates Feedback",
"feature_request": "Funktionswunsch",
"testimonial": "Empfehlung"
}
},
"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.",
"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.",
"auditPreviousParticipation": "Löschbestätigungen beziehen sich auf eine frühere Teilnahme, nicht auf die aktuelle."
},
"userManagement": {
"title": "Benutzerverwaltung",
+8 -3
View File
@@ -353,7 +353,7 @@
},
"gallery_image_protection": {
"name": "Image protection enabled",
"configured": "Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts."
"configured": "Enabled beyond the shipped defaults — a stronger protection level, canvas rendering, or right-click disabled — globally or on at least one gallery; only existence across the installation, never gallery IDs or counts."
},
"gallery_reveal": {
"name": "Gallery reveal enabled",
@@ -371,7 +371,7 @@
"noticeTitle": "Help shape PicPeak",
"notice": "Optional product usage reports show which features matter to the community. Reporting is off until you choose to participate.",
"ignore": "Ignore",
"ignoreHint": "This notice won't appear again — you can still join from Settings → Product usage.",
"ignoreHint": "If you ignore this, the notice won't appear again — you can still join from Settings → Product usage.",
"review": "Review participation",
"cancel": "Cancel",
"loading": "Loading participation settings…",
@@ -437,7 +437,12 @@
"feedback": "Private feedback",
"feature_request": "Feature request",
"testimonial": "Testimonial"
}
},
"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.",
"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.",
"auditPreviousParticipation": "Deletion confirmations refer to an earlier participation, not the current one."
},
"userManagement": {
"title": "User Management",
@@ -17,6 +17,10 @@ export interface UsageStatus {
consent_update_available?: boolean;
last_report_date: string | null;
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. */
can_abandon?: boolean;
pending_action: string | null;
last_packet: unknown;
privacy_receipts?: Record<string, unknown>;
@@ -56,6 +60,9 @@ export const productUsageService = {
async retry(): Promise<UsageStatus> {
return (await api.post('/admin/usage/retry')).data;
},
async abandon(): Promise<UsageStatus> {
return (await api.post('/admin/usage/abandon')).data;
},
async preview(): Promise<unknown> {
return (await api.get('/admin/usage/preview')).data;
},