fix(usage): preserve report contracts with compatible receiver validation

This commit is contained in:
Paul Nothaft
2026-09-06 19:45:41 +02:00
parent b0bb65d0d2
commit 7ca783f89b
4 changed files with 92 additions and 2 deletions
+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,
};