fix(usage): minimize session receipts and clarify privacy controls

This commit is contained in:
Paul Nothaft
2026-09-05 23:44:15 +02:00
parent cc263f2e87
commit e347f8f40f
12 changed files with 192 additions and 17 deletions
@@ -50,6 +50,7 @@ maybe('product usage on Postgres', () => {
await require('../../migrations/core/201_product_usage').up(db);
await require('../../migrations/core/202_product_usage_cancel_requested').up(db);
await require('../../migrations/core/203_product_usage_cancel_seq').up(db);
await require('../../migrations/core/204_product_usage_privacy_receipts').up(db);
await db.schema.createTable('app_settings', (t) => {
t.string('setting_key').primary(); t.text('setting_value'); t.string('setting_type');
@@ -110,6 +111,18 @@ maybe('product usage on Postgres', () => {
expect(cols.cancel_seq).toBeDefined();
expect(cols.cancel_requested).toBeUndefined(); // dropped by 203
expect(cols.sequence).toBeDefined();
expect(cols.privacy_receipts).toBeDefined();
});
it('reruns the receipt migration safely and scrubs legacy plaintext sessions', async () => {
const migration = require('../../migrations/core/204_product_usage_privacy_receipts');
await db('product_usage_state').where({ id: 1 }).update({
last_receipt: JSON.stringify({ status: 'accepted', session_token: 'synthetic-old-token' })
});
await migration.up(db);
await migration.up(db);
const row = await db('product_usage_state').where({ id: 1 }).first();
expect(JSON.parse(row.last_receipt)).toEqual({ status: 'accepted' });
});
it('reads bigint cancel_seq correctly even though pg returns it as a string', async () => {
@@ -49,6 +49,7 @@ async function bootDb() {
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');
@@ -32,6 +32,7 @@ async function bootDb() {
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');
@@ -27,6 +27,7 @@ async function bootDb() {
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);
@@ -0,0 +1,34 @@
// Bounded, local-only audit receipts. Never retain an installation identity,
// signing key, report/feedback payload or collector credential after opt-out.
exports.up = async function (knex) {
if (
(await knex.schema.hasTable('product_usage_state')) &&
!(await knex.schema.hasColumn('product_usage_state', 'privacy_receipts'))
) {
await knex.schema.alterTable('product_usage_state', (t) =>
t.text('privacy_receipts')
);
}
if (await knex.schema.hasColumn('product_usage_state', 'last_receipt')) {
const row = await knex('product_usage_state').where({ id: 1 }).first();
if (row?.last_receipt) {
const receipt = JSON.parse(row.last_receipt);
if (receipt.session_token) {
delete receipt.session_token;
await knex('product_usage_state')
.where({ id: 1 })
.update({ last_receipt: JSON.stringify(receipt) });
}
}
}
};
exports.down = async function (knex) {
if (
(await knex.schema.hasTable('product_usage_state')) &&
(await knex.schema.hasColumn('product_usage_state', 'privacy_receipts'))
) {
await knex.schema.alterTable('product_usage_state', (t) =>
t.dropColumn('privacy_receipts')
);
}
};
+53 -2
View File
@@ -239,6 +239,9 @@ class UsageService {
? JSON.parse(state.pending_packet).action
: null,
last_packet: state.last_packet ? JSON.parse(state.last_packet) : null,
privacy_receipts: state.privacy_receipts
? JSON.parse(state.privacy_receipts)
: {},
feedback_preferences: state.feedback_preferences
? JSON.parse(state.feedback_preferences)
: { name: '' }
@@ -458,17 +461,37 @@ class UsageService {
pending_packet: null,
last_packet: null,
last_receipt: null,
privacy_receipts: JSON.stringify({
last_deletion: {
receipt_version: 'local-audit.v1',
kind: 'deletion',
receipt_id: crypto.randomUUID(),
confirmed_at: new Date(this.now()).toISOString(),
status: 'collector-confirmed',
scope: [
'reports',
'snapshots',
'feedback',
'votes',
'sessions',
'operations',
'registration'
]
}
}),
last_report_date: null,
last_error: null,
sequence: 0,
feedback_preferences: null
});
} else {
const storedReceipt = { ...receipt };
delete storedReceipt.session_token;
const update = {
sequence: packet.sequence,
pending_packet: null,
last_error: null,
last_receipt: JSON.stringify(receipt)
last_receipt: JSON.stringify(storedReceipt)
};
if (packet.action === 'report') {
update.last_packet = JSON.stringify(envelope);
@@ -805,11 +828,39 @@ class UsageService {
if (!state.installation_id) throw new ConflictError('No usage identity');
// Own-data export includes the complete retained history, not a truncated
// packet subset. The acceptance/receipt path above stays strictly bounded.
return this.post(
const result = await this.post(
'/api/participant/lookup',
{ installation_id: state.installation_id },
Infinity
);
// Only the last export during this participation is retained locally.
// Do not restore audit state if opt-out/identity replacement happened while
// the export was in flight. The downloaded file carries the full receipt.
const receipts = state.privacy_receipts
? JSON.parse(state.privacy_receipts)
: {};
await this.db('product_usage_state')
.where({
id: 1,
status: 'active',
installation_id: state.installation_id
})
.update({
privacy_receipts: JSON.stringify({
...receipts,
last_export: {
receipt_version: 'local-audit.v1',
kind: 'export',
receipt_id: crypto.randomUUID(),
confirmed_at: new Date(this.now()).toISOString(),
report_count: Array.isArray(result.packets)
? result.packets.length
: 0,
scope: ['unique accepted usage reports']
}
})
});
return result;
}
}
module.exports = { UsageService, FLAG_MAP };