fix(usage): minimize session receipts and clarify privacy controls
This commit is contained in:
@@ -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')
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user