Merge pull request #1313 from PicPeak/codex/usage-report-compatibility
fix(usage): preserve compatibility with old and partial reports
This commit is contained in:
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@ const Ajv = require("ajv");
|
|||||||
const {
|
const {
|
||||||
envelopeSchema,
|
envelopeSchema,
|
||||||
envelopeSchemas,
|
envelopeSchemas,
|
||||||
|
ingressEnvelopeSchemas,
|
||||||
CURRENT_SCHEMA_VERSION,
|
CURRENT_SCHEMA_VERSION,
|
||||||
FEATURE_KEYS,
|
FEATURE_KEYS,
|
||||||
LAYOUTS,
|
LAYOUTS,
|
||||||
@@ -15,6 +16,11 @@ const validators = new Map(Object.entries(envelopeSchemas).map(
|
|||||||
));
|
));
|
||||||
const validate = (envelope) =>
|
const validate = (envelope) =>
|
||||||
Boolean(validators.get(envelope?.packet?.schema_version)?.(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_BYTES = 16384;
|
||||||
const MAX_AGE_MS = 5 * 60 * 1000;
|
const MAX_AGE_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
@@ -90,9 +96,15 @@ function signPacket(packet, identity, now = new Date()) {
|
|||||||
return envelope;
|
return envelope;
|
||||||
}
|
}
|
||||||
function verifyEnvelope(envelope, now = Date.now()) {
|
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 (
|
if (
|
||||||
Buffer.byteLength(JSON.stringify(envelope) || "") > MAX_BYTES ||
|
Buffer.byteLength(JSON.stringify(envelope) || "") > MAX_BYTES ||
|
||||||
!validate(envelope)
|
!validateEnvelope(envelope)
|
||||||
)
|
)
|
||||||
throw new ProtocolError("INVALID_PACKET");
|
throw new ProtocolError("INVALID_PACKET");
|
||||||
const issued = Date.parse(envelope.issued_at);
|
const issued = Date.parse(envelope.issued_at);
|
||||||
@@ -151,6 +163,7 @@ module.exports = {
|
|||||||
makePacket,
|
makePacket,
|
||||||
signPacket,
|
signPacket,
|
||||||
verifyEnvelope,
|
verifyEnvelope,
|
||||||
|
verifyReceivedEnvelope,
|
||||||
ProtocolError,
|
ProtocolError,
|
||||||
MAX_BYTES,
|
MAX_BYTES,
|
||||||
MAX_AGE_MS,
|
MAX_AGE_MS,
|
||||||
|
|||||||
@@ -82,9 +82,35 @@ const envelopeSchemas = Object.fromEntries(Object.entries(payloadsByVersion).map
|
|||||||
}]));
|
}]));
|
||||||
const envelopeSchema = envelopeSchemas[CURRENT_SCHEMA_VERSION];
|
const envelopeSchema = envelopeSchemas[CURRENT_SCHEMA_VERSION];
|
||||||
const payloads = payloadsByVersion[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 = {
|
module.exports = {
|
||||||
FEATURE_KEYS, LEGACY_FEATURE_KEYS, LAYOUTS, CATALOG, CATALOGS, CONSENT_VERSIONS,
|
FEATURE_KEYS, LEGACY_FEATURE_KEYS, LAYOUTS, CATALOG, CATALOGS, CONSENT_VERSIONS,
|
||||||
schemaForConsent, schemaRank, INVENTORY_KEYS, MAX_INVENTORY_COUNT, CURRENT_SCHEMA_VERSION,
|
schemaForConsent, schemaRank, INVENTORY_KEYS, MAX_INVENTORY_COUNT, CURRENT_SCHEMA_VERSION,
|
||||||
CURRENT_CONSENT_VERSION, featureKeysFor, observesUse, emptyFeatures,
|
CURRENT_CONSENT_VERSION, featureKeysFor, observesUse, emptyFeatures,
|
||||||
envelopeSchema, envelopeSchemas, payloads, payloadsByVersion,
|
envelopeSchema, envelopeSchemas, ingressEnvelopeSchemas, payloads, payloadsByVersion,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,6 +16,16 @@ The sections below also document the historical v1/v2 implementation. Any
|
|||||||
statements excluding all gallery/photo counts describe those earlier versions;
|
statements excluding all gallery/photo counts describe those earlier versions;
|
||||||
v3 adds only the two installation totals above.
|
v3 adds only the two installation totals above.
|
||||||
|
|
||||||
|
Backward compatibility is required for future changes. The collector continues
|
||||||
|
to accept v1/v2/v3 reports, including omitted or null measurements, using their
|
||||||
|
declared schema and original reporting day. Missing values remain unknown in
|
||||||
|
aggregates and histories. PicPeak still emits complete reports through the
|
||||||
|
unchanged sender schemas; only reception is more tolerant. Consent, field
|
||||||
|
allowlists, signatures and retry/deletion rules remain mandatory. Deploy the
|
||||||
|
compatible collector first; old PicPeak clients require no update or renewed
|
||||||
|
consent to keep delivering their existing report scope. New fields still
|
||||||
|
require a new explicitly consented schema; never redefine an old field.
|
||||||
|
|
||||||
|
|
||||||
Tracking is disabled by default. After updating, settings editors see a
|
Tracking is disabled by default. After updating, settings editors see a
|
||||||
dismissible invitation in the admin shell. Only explicit consent in Settings →
|
dismissible invitation in the admin shell. Only explicit consent in Settings →
|
||||||
|
|||||||
Reference in New Issue
Block a user