Merge pull request #1313 from PicPeak/codex/usage-report-compatibility

fix(usage): preserve compatibility with old and partial reports
This commit is contained in:
Paul Nothaft
2026-09-06 19:54:33 +02:00
committed by GitHub
4 changed files with 92 additions and 2 deletions
@@ -0,0 +1,41 @@
const crypto = require('crypto');
const p = require('../../src/usage/protocol.cjs');
const now = Date.parse('2026-09-06T12:00:00.000Z');
const signHistorical = (packet, id) => {
const signed = { packet, public_key: id.public_key, issued_at: new Date(now).toISOString(), nonce: crypto.randomUUID() };
return { ...signed, signature: crypto.sign(null, Buffer.from(p.canonical(signed)), id.private_key).toString('base64url') };
};
describe.each(['usage.v1', 'usage.v2', 'usage.v3'])('%s receiver compatibility never loosens the PicPeak sender', version => {
test('complete original reports still sign and verify', () => {
const id = p.generateIdentity();
const packet = p.makePacket(id, 'report', 1, {
picpeak_version: '1.0.0', report_date: '2026-09-06', generated_at: new Date(now).toISOString(),
features: p.emptyFeatures(version), gallery_layouts: [],
...(version === 'usage.v3' ? { inventory: { galleries: 0, photos: 0 } } : {}),
}, version);
const envelope = p.signPacket(packet, id, new Date(now));
expect(p.verifyEnvelope(envelope, now)).toEqual(packet);
expect(p.verifyReceivedEnvelope(envelope, now)).toEqual(packet);
});
test.each([undefined, null, {}, { crm: { used: true, configured: null } }])('accepts partial incoming measurements %p without rewriting them', features => {
const id = p.generateIdentity();
const packet = p.makePacket(id, 'report', 1, {
report_date: '2020-01-02', generated_at: '2020-01-02T12:00:00.000Z',
...(features === undefined ? {} : { features }),
}, version);
expect(() => p.signPacket(packet, id, new Date(now))).toThrow('INVALID_PACKET');
const envelope = signHistorical(packet, id), original = JSON.stringify(envelope);
expect(() => p.verifyEnvelope(envelope, now)).toThrow('INVALID_PACKET');
expect(p.verifyReceivedEnvelope(envelope, now)).toEqual(packet);
expect(JSON.stringify(envelope)).toBe(original);
});
test('registration still requires exact explicit consent', () => {
const id = p.generateIdentity();
const packet = p.makePacket(id, 'register', 0, {}, version);
expect(() => p.verifyReceivedEnvelope(signHistorical(packet, id), now)).toThrow('INVALID_PACKET');
});
});
+14 -1
View File
@@ -4,6 +4,7 @@ const Ajv = require("ajv");
const {
envelopeSchema,
envelopeSchemas,
ingressEnvelopeSchemas,
CURRENT_SCHEMA_VERSION,
FEATURE_KEYS,
LAYOUTS,
@@ -15,6 +16,11 @@ const validators = new Map(Object.entries(envelopeSchemas).map(
));
const validate = (envelope) =>
Boolean(validators.get(envelope?.packet?.schema_version)?.(envelope));
const ingressValidators = new Map(Object.entries(ingressEnvelopeSchemas).map(
([version, schema]) => [version, ajv.compile(schema)],
));
const validateIngress = (envelope) =>
Boolean(ingressValidators.get(envelope?.packet?.schema_version)?.(envelope));
const MAX_BYTES = 16384;
const MAX_AGE_MS = 5 * 60 * 1000;
@@ -90,9 +96,15 @@ function signPacket(packet, identity, now = new Date()) {
return envelope;
}
function verifyEnvelope(envelope, now = Date.now()) {
return verify(envelope, now, validate);
}
function verifyReceivedEnvelope(envelope, now = Date.now()) {
return verify(envelope, now, validateIngress);
}
function verify(envelope, now, validateEnvelope) {
if (
Buffer.byteLength(JSON.stringify(envelope) || "") > MAX_BYTES ||
!validate(envelope)
!validateEnvelope(envelope)
)
throw new ProtocolError("INVALID_PACKET");
const issued = Date.parse(envelope.issued_at);
@@ -151,6 +163,7 @@ module.exports = {
makePacket,
signPacket,
verifyEnvelope,
verifyReceivedEnvelope,
ProtocolError,
MAX_BYTES,
MAX_AGE_MS,
+27 -1
View File
@@ -82,9 +82,35 @@ const envelopeSchemas = Object.fromEntries(Object.entries(payloadsByVersion).map
}]));
const envelopeSchema = envelopeSchemas[CURRENT_SCHEMA_VERSION];
const payloads = payloadsByVersion[CURRENT_SCHEMA_VERSION];
// Writers retain their immutable, complete contracts. The receiver accepts
// missing measurements from older/partial reporters, without expanding any
// version's allowlist or changing the signed object. Null also means unknown.
const nullable = (schema) => ({ anyOf: [schema, { type: "null" }] });
const ingressEnvelopeSchemas = Object.fromEntries(Object.entries(envelopeSchemas).map(([version, source]) => {
const schema = structuredClone(source);
schema.$id = `https://usage.picpeak.app/schema/ingress/${version}.json`;
schema.title = `PicPeak ${version} compatible report reception`;
const payload = schema.properties.packet.oneOf.find(p => p.properties.action.const === "report").properties.payload;
payload.required = ["report_date", "generated_at"];
const features = payload.properties.features;
features.required = [];
for (const signal of Object.values(features.properties)) {
signal.required = [];
for (const key of Object.keys(signal.properties)) signal.properties[key] = nullable(signal.properties[key]);
}
for (const key of Object.keys(features.properties)) features.properties[key] = nullable(features.properties[key]);
if (payload.properties.inventory) {
const inventory = payload.properties.inventory;
inventory.required = [];
for (const key of Object.keys(inventory.properties)) inventory.properties[key] = nullable(inventory.properties[key]);
}
for (const key of ["picpeak_version", "features", "gallery_layouts", "inventory"])
if (payload.properties[key]) payload.properties[key] = nullable(payload.properties[key]);
return [version, schema];
}));
module.exports = {
FEATURE_KEYS, LEGACY_FEATURE_KEYS, LAYOUTS, CATALOG, CATALOGS, CONSENT_VERSIONS,
schemaForConsent, schemaRank, INVENTORY_KEYS, MAX_INVENTORY_COUNT, CURRENT_SCHEMA_VERSION,
CURRENT_CONSENT_VERSION, featureKeysFor, observesUse, emptyFeatures,
envelopeSchema, envelopeSchemas, payloads, payloadsByVersion,
envelopeSchema, envelopeSchemas, ingressEnvelopeSchemas, payloads, payloadsByVersion,
};