feat: add opt-in product usage and feedback integration (#1110)

This commit is contained in:
Paul Nothaft
2026-09-05 12:59:06 +02:00
parent c71ffae912
commit b53e5d97b4
24 changed files with 2360 additions and 18 deletions
+50
View File
@@ -0,0 +1,50 @@
// Only successful authenticated ADMIN capability operations set a coarse
// marker. Never mount on gallery/customer/public routes. No request values,
// identifiers, paths, timing, or counts are retained or sent.
const service = require('../services/productUsageService');
const logger = require('../utils/logger');
const RULES = [
[/^\/customers(?:\/|$)/, ['crm']],
[/^\/quotes(?:\/|$)/, ['crm', 'crm_quotes']],
[/^\/invoices(?:\/|$)/, ['crm', 'crm_invoices']],
[/^\/contracts(?:\/|$)/, ['crm', 'crm_contracts']],
[/^\/projects(?:\/|$)/, ['crm', 'crm_projects']],
[/^\/calendar(?:\/|$)/, ['crm', 'crm_calendar']],
[/^\/customers\/(?:[^/]+\/)?hour-entries(?:\/|$)/, ['crm', 'crm_hours']],
[/^\/customers\/(?:invite|[^/]+\/send-invite)(?:\/|$)/, ['customer_portal']],
[
/^\/(?:ledger|expenses|tax-report|incoming-invoices)(?:\/|$)/,
['accounting']
],
[/^\/workflows(?:\/|$)/, ['workflows']],
[/^\/newsletters(?:\/|$)/, ['newsletters']],
[/^\/events\/[^/]+\/(?:faces|people)(?:\/|$)/, ['face_recognition']],
[/^\/whatsapp\/(?:send|test)(?:\/|$)/, ['whatsapp']],
[
/^\/(?:backup|database-backup)\/(?:run|backup|create|start|test|picpeak\/export)(?:\/|$)/,
['backup']
],
[/^\/backup\/s3\/test-upload(?:\/|$)/, ['s3_storage']],
[/^\/email\/(?:test|send)(?:\/|$)/, ['smtp']],
[/^\/external-media(?:\/|$)/, ['share_mounts']]
];
function productUsage(req, res, next) {
const pathname = req.path;
res.once('finish', () => {
if (!req.admin?.id || res.statusCode < 200 || res.statusCode >= 300) return;
const features = RULES.filter(([pattern]) =>
pattern.test(pathname)
).flatMap(([, keys]) => keys);
if (
process.env.STORAGE_BACKEND === 's3' &&
/^\/(?:photos|events)\/[^/]+\/upload(?:\/|$)/.test(pathname)
)
features.push('s3_storage');
if (features.length)
service
.markUsed(features)
.catch(() => logger.warn('Product usage marker could not be recorded'));
});
next();
}
module.exports = { productUsage, RULES };
+118
View File
@@ -0,0 +1,118 @@
const express = require('express');
const crypto = require('crypto');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { ValidationError } = require('../utils/errors');
const service = require('../services/productUsageService');
const router = express.Router();
const wrap = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res)).catch((error) => {
if (error.name === 'ProtocolError')
return res
.status(400)
.json({ error: 'Invalid usage request', code: error.code });
next(error);
});
router.use(adminAuth);
router.use((_req, res, next) => {
res.set('Cache-Control', 'no-store');
next();
});
// Any authenticated admin can trigger the daily rollup; only settings editors
// see identity/packets or control consent. The route never accepts telemetry.
router.post(
'/activity',
wrap(async (_req, res) => {
try {
await service.tick();
} catch (error) {
if (error.code !== 'CONFLICT') throw error;
}
res.json({ ok: true });
})
);
router.use(requirePermission('settings.edit'));
router.get(
'/',
wrap(async (_req, res) => res.json(await service.status()))
);
router.post(
'/dismiss',
wrap(async (_req, res) => res.json(await service.dismiss()))
);
router.post(
'/enable',
wrap(async (req, res) =>
res.json(await service.enable(req.body.consent_version))
)
);
router.post(
'/disable',
wrap(async (_req, res) => res.json(await service.disable()))
);
router.post(
'/retry',
wrap(async (_req, res) => res.json(await service.tick()))
);
router.get(
'/preview',
wrap(async (_req, res) => res.json(await service.preview()))
);
router.get(
'/export',
wrap(async (_req, res) =>
res.attachment('picpeak-usage-packets.json').json(await service.export())
)
);
router.put(
'/feedback-preferences',
wrap(async (req, res) => res.json(await service.preferences(req.body)))
);
router.post(
'/feedback',
wrap(async (req, res) => {
const body = req.body;
if (
!body ||
Object.keys(body).some(
(k) =>
![
'kind',
'title',
'body',
'name',
'allow_public',
'allow_marketing'
].includes(k)
) ||
typeof body.title !== 'string' ||
!body.title.trim() ||
typeof body.body !== 'string' ||
!body.body.trim()
)
throw new ValidationError('Invalid feedback');
res.json(
await service.command('feedback', {
...body,
feedback_id: crypto.randomUUID()
})
);
})
);
router.post(
'/vote',
wrap(async (req, res) => res.json(await service.command('vote', req.body)))
);
router.post(
'/portal-session',
wrap(async (_req, res) => {
const result = await service.command('session', {});
res.json({
...result,
url: result.receipt?.session_token
? `${service.collectorUrl()}/#connect=${encodeURIComponent(result.receipt.session_token)}`
: null
});
})
);
module.exports = router;
+4
View File
@@ -1145,6 +1145,10 @@ router.get('/admin/sso/callback', async (req, res) => {
await logActivity('admin_sso_login', { provider: 'oidc' }, null, {
type: 'admin', id: admin.id, name: admin.username,
});
// Only a successful ADMIN SSO callback sets this opt-in capability marker.
// No token, claim, address, or account identifier reaches product usage.
require('../services/productUsageService').markUsed(['oauth'])
.catch(() => logger.warn('Product usage marker could not be recorded'));
return res.redirect(`${frontendBase}/admin/dashboard`);
} catch (error) {
@@ -0,0 +1,3 @@
const { db } = require('../database/db');
const { UsageService } = require('../usage/UsageService');
module.exports = new UsageService(db);
+639
View File
@@ -0,0 +1,639 @@
'use strict';
const crypto = require('crypto');
const fs = require('fs/promises');
const path = require('path');
const { getStoragePath } = require('../config/storage');
const { formatBoolean } = require('../utils/dbCompat');
const {
ConflictError,
ValidationError,
ServiceUnavailableError
} = require('../utils/errors');
const {
generateIdentity,
makePacket,
signPacket,
verifyEnvelope,
digest,
canonical,
FEATURE_KEYS,
LAYOUTS
} = require('./protocol.cjs');
const FLAG_MAP = {
crm: 'clients',
crm_quotes: 'quotes',
crm_invoices: 'bills',
crm_contracts: 'contracts',
crm_projects: 'projects',
crm_calendar: 'calendar',
crm_hours: 'hoursLogging',
customer_portal: 'customerPortal',
accounting: 'accounting',
workflows: 'workflows',
newsletters: 'newsletters',
face_recognition: 'faces',
whatsapp: 'whatsapp'
};
const SETTING_KEYS = [
'oidc_enabled',
'oidc_issuer_url',
'oidc_client_id',
'backup_enabled',
'backup_destination_type',
'backup_s3_bucket',
'theme_config',
'general_custom_css',
'general_public_site_custom_css'
];
const truth = (value) => value === true || value === 1 || value === '1';
const parse = (value) => {
try {
return JSON.parse(value);
} catch (_) {
return value;
}
};
class UsageService {
constructor(db, options = {}) {
this.db = db;
this.fetch = options.fetch || global.fetch;
this.now = options.now || (() => Date.now());
this.version = options.version || require('../../package.json').version;
this.secret =
options.secret ||
process.env.USAGE_ENCRYPTION_KEY ||
process.env.JWT_SECRET;
this.endpoint =
options.endpoint ||
process.env.USAGE_COLLECTOR_URL ||
'https://usage.picpeak.app';
this.bindingPath =
options.bindingPath || path.join(getStoragePath(), 'usage-instance.key');
this.encKey = null;
}
collectorUrl() {
const url = new URL(this.endpoint);
const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
if (
url.username ||
url.password ||
url.search ||
url.hash ||
url.pathname !== '/' ||
(url.protocol !== 'https:' &&
!(
url.protocol === 'http:' &&
loopback &&
process.env.NODE_ENV !== 'production'
))
) {
throw new ValidationError('Invalid usage collector URL');
}
return url.origin;
}
key() {
if (!this.secret || this.secret.length < 32)
throw new ServiceUnavailableError(
'Usage signing-key encryption is not configured'
);
if (!this.encKey)
this.encKey = crypto.scryptSync(
this.secret,
'picpeak-product-usage-v1',
32
);
return this.encKey;
}
encrypt(value) {
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', this.key(), iv);
const data = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]);
return [iv, cipher.getAuthTag(), data]
.map((v) => v.toString('base64url'))
.join('.');
}
decrypt(value) {
const [iv, tag, data] = value
.split('.')
.map((v) => Buffer.from(v, 'base64url'));
const cipher = crypto.createDecipheriv('aes-256-gcm', this.key(), iv);
cipher.setAuthTag(tag);
return Buffer.concat([cipher.update(data), cipher.final()]).toString(
'utf8'
);
}
async binding(create = false) {
if (create) {
await fs.mkdir(path.dirname(this.bindingPath), { recursive: true });
try {
await fs.writeFile(
this.bindingPath,
crypto.randomBytes(32).toString('hex'),
{ flag: 'wx', mode: 0o600 }
);
} catch (error) {
if (error.code !== 'EEXIST') throw error;
}
}
try {
return digest(await fs.readFile(this.bindingPath));
} catch (error) {
if (error.code === 'ENOENT') return null;
throw error;
}
}
async state() {
return this.db('product_usage_state').where({ id: 1 }).first();
}
async status() {
const state = await this.state();
return {
status: state.status,
notice_dismissed: Boolean(state.notice_dismissed),
installation_id: state.installation_id,
collector_url: this.collectorUrl(),
schema_version: 'usage.v1',
last_report_date: state.last_report_date,
last_error: state.last_error,
pending_action: state.pending_packet
? JSON.parse(state.pending_packet).action
: null,
last_packet: state.last_packet ? JSON.parse(state.last_packet) : null,
feedback_preferences: state.feedback_preferences
? JSON.parse(state.feedback_preferences)
: { name: '' }
};
}
async locked(fn) {
const token = crypto.randomUUID();
const updated = await this.db('product_usage_state')
.where({ id: 1 })
.where('lease_until', '<=', this.now())
.update({ lease_token: token, lease_until: this.now() + 60000 });
if (!updated)
throw new ConflictError('Usage operation is already in progress');
try {
return await fn(await this.state());
} finally {
await this.db('product_usage_state')
.where({ id: 1, lease_token: token })
.update({ lease_token: null, lease_until: 0 });
}
}
async dismiss() {
await this.db('product_usage_state')
.where({ id: 1 })
.update({ notice_dismissed: formatBoolean(true) });
return this.status();
}
async enable(consent) {
if (consent !== 'usage-consent.v1')
throw new ValidationError('Explicit usage consent is required');
await this.locked(async (state) => {
if (state.status !== 'disabled')
throw new ConflictError(
'Finish the current participation before rejoining'
);
this.collectorUrl();
const identity = generateIdentity();
const pending = makePacket(identity, 'register', 0, {
consent_version: consent
});
await this.db('product_usage_state')
.where({ id: 1 })
.update({
status: 'activation_pending',
notice_dismissed: formatBoolean(true),
installation_id: identity.installation_id,
public_key: identity.public_key,
private_key_encrypted: this.encrypt(identity.private_key),
instance_binding: await this.binding(true),
sequence: 0,
pending_packet: JSON.stringify(pending),
last_error: null
});
await this.deliver(await this.state());
});
return this.status();
}
async disable() {
// Stop collection before waiting for an in-flight send. The sender checks
// state again before delivery and preserves this stop after its response.
await this.db('product_usage_state')
.where({ id: 1 })
.whereNot({ status: 'disabled' })
.update({
status: 'deletion_pending',
feedback_preferences: null,
pending_packet: null,
last_packet: null,
last_receipt: null,
last_report_date: null
});
await this.db('product_usage_markers').delete();
try {
await this.tick();
} catch (error) {
// A sender may still own the lease. Collection is already stopped and
// the next admin activity retries deletion after that sender finishes.
if (error.code !== 'CONFLICT') throw error;
}
return this.status();
}
async post(pathname, body, maxResponseBytes = 65536) {
const response = await this.fetch(`${this.collectorUrl()}${pathname}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
redirect: 'error',
signal: AbortSignal.timeout(10000)
});
if (Number(response.headers.get('content-length') || 0) > maxResponseBytes)
throw new ServiceUnavailableError('Invalid collector response');
let raw = '';
let bytes = 0;
const decoder = new TextDecoder();
for await (const chunk of response.body) {
bytes += chunk.length;
if (bytes > maxResponseBytes)
throw new ServiceUnavailableError('Invalid collector response');
raw += decoder.decode(chunk, { stream: true });
}
raw += decoder.decode();
const value = JSON.parse(raw);
if (!response.ok) {
const error = new Error('Collector rejected operation');
error.code = value.error;
throw error;
}
return value;
}
async deliver(state) {
const packet = JSON.parse(state.pending_packet);
if (
packet.action !== 'delete' &&
(await this.state()).status === 'deletion_pending'
)
return null;
try {
if (
packet.action !== 'delete' &&
state.instance_binding !== (await this.binding())
) {
await this.db('product_usage_state')
.where({ id: 1 })
.update({
status: 'identity_conflict',
last_error: 'INSTANCE_COPY_DETECTED'
});
return null;
}
const envelope = signPacket(
packet,
{
public_key: state.public_key,
private_key: this.decrypt(state.private_key_encrypted)
},
new Date(this.now())
);
const receipt = await this.post('/api/envelopes', envelope);
if (
receipt.packet_id !== packet.packet_id ||
receipt.installation_id !== packet.installation_id ||
receipt.packet_digest !== digest(canonical(packet)) ||
receipt.action !== packet.action ||
receipt.sequence !== packet.sequence ||
receipt.status !== (packet.action === 'delete' ? 'deleted' : 'accepted')
) {
throw new Error('Invalid collector receipt');
}
if (packet.action === 'delete') {
await fs.unlink(this.bindingPath).catch((error) => {
if (error.code !== 'ENOENT') throw error;
});
await this.db('product_usage_markers').delete();
await this.db('product_usage_state')
.where({ id: 1 })
.update({
status: 'disabled',
installation_id: null,
public_key: null,
private_key_encrypted: null,
instance_binding: null,
pending_packet: null,
last_packet: null,
last_receipt: null,
last_report_date: null,
last_error: null,
sequence: 0,
feedback_preferences: null
});
} else {
const update = {
sequence: packet.sequence,
pending_packet: null,
last_error: null,
last_receipt: JSON.stringify(receipt)
};
if (packet.action === 'report') {
update.last_packet = JSON.stringify(envelope);
update.last_report_date = packet.payload.report_date;
}
await this.db('product_usage_state')
.where({ id: 1 })
.whereNot({ status: 'deletion_pending' })
.update(update);
await this.db('product_usage_state')
.where({ id: 1, status: 'deletion_pending' })
.update({ sequence: packet.sequence, pending_packet: null });
if (packet.action === 'register')
await this.db('product_usage_state')
.where({ id: 1, status: 'activation_pending' })
.update({ status: 'active' });
}
return receipt;
} catch (error) {
const rejected = [
'INVALID_PACKET',
'INVALID_REPORT_DATE',
'INVALID_PUBLICATION_CONSENT',
'REQUEST_NOT_FOUND',
'FEEDBACK_CONFLICT'
].includes(error.code);
if (rejected && ['feedback', 'vote', 'session'].includes(packet.action)) {
await this.db('product_usage_state')
.where({ id: 1 })
.whereNot({ status: 'deletion_pending' })
.update({ pending_packet: null, last_error: 'REQUEST_REJECTED' });
return null;
}
const conflict = [
'SEQUENCE_CONFLICT',
'IDENTITY_CONFLICT',
'IDENTITY_REVOKED',
'NOT_REGISTERED',
'PACKET_CONFLICT'
].includes(error.code);
const code = conflict ? error.code : 'DELIVERY_FAILED';
await this.db('product_usage_state')
.where({ id: 1 })
.update({ last_error: code });
if (conflict && packet.action !== 'delete') {
await this.db('product_usage_state')
.where({ id: 1 })
.whereNot({ status: 'deletion_pending' })
.update({ status: 'identity_conflict' });
}
return null;
}
}
async tick() {
await this.locked(async (state) => {
if (state.status === 'disabled') return;
if (state.status === 'deletion_pending') {
const packet = makePacket(state, 'delete', Number(state.sequence), {});
state.pending_packet = JSON.stringify(packet);
await this.db('product_usage_state')
.where({ id: 1 })
.update({ pending_packet: state.pending_packet });
await this.deliver(state);
return;
}
if (state.status === 'identity_conflict') return;
if (state.pending_packet) {
await this.deliver(state);
state = await this.state();
}
if (state.status !== 'active' || state.pending_packet) return;
if (
state.last_report_date ===
new Date(this.now()).toISOString().slice(0, 10)
)
return;
const payload = await this.snapshot();
const packet = makePacket(
state,
'report',
Number(state.sequence) + 1,
payload
);
state.pending_packet = JSON.stringify(packet);
await this.db('product_usage_state')
.where({ id: 1 })
.update({ pending_packet: state.pending_packet });
await this.deliver(state);
});
return this.status();
}
async markUsed(features) {
const allowed = [...new Set(features)].filter((f) =>
FEATURE_KEYS.includes(f)
);
if (!allowed.length) return;
// Single-transaction status check prevents opt-out racing a late marker.
await this.db.transaction(async (tx) => {
const query = tx('product_usage_state').where({ id: 1 });
if (this.db.client.config.client === 'pg') query.forUpdate();
const state = await query.first();
if (!state || state.status !== 'active') return;
if (allowed.includes('backup')) {
const destination = await tx('app_settings')
.where({ setting_key: 'backup_destination_type' })
.first();
if (destination && parse(destination.setting_value) === 's3')
allowed.push('s3_storage');
}
await tx('product_usage_markers')
.insert(allowed.map((feature) => ({ feature })))
.onConflict('feature')
.ignore();
});
}
async snapshot() {
const rows = await this.db('app_settings')
.whereIn('setting_key', SETTING_KEYS)
.select('setting_key', 'setting_value');
const settings = Object.fromEntries(
rows.map((r) => [r.setting_key, parse(r.setting_value)])
);
const flagRows = await this.db('feature_flags')
.whereIn('key', Object.values(FLAG_MAP))
.select('key', 'value');
const flags = Object.fromEntries(
flagRows.map((r) => [r.key, truth(r.value)])
);
const used = new Set(
await this.db('product_usage_markers').pluck('feature')
);
const features = Object.fromEntries(
FEATURE_KEYS.map((key) => [
key,
{ configured: Boolean(flags[FLAG_MAP[key]]), used: used.has(key) }
])
);
features.oauth.configured =
truth(settings.oidc_enabled) &&
Boolean(settings.oidc_issuer_url && settings.oidc_client_id);
features.backup.configured = truth(settings.backup_enabled);
features.s3_storage.configured =
(settings.backup_destination_type === 's3' &&
Boolean(settings.backup_s3_bucket)) ||
(process.env.STORAGE_BACKEND === 's3' &&
Boolean(
process.env.STORAGE_S3_BUCKET &&
process.env.STORAGE_S3_ACCESS_KEY &&
process.env.STORAGE_S3_SECRET_KEY
));
features.share_mounts.configured = Boolean(
await this.db('events')
.whereNotNull('external_path')
.whereNot('external_path', '')
.select('id')
.first()
);
features.smtp.configured =
Boolean(
await this.db('email_configs')
.whereNotNull('smtp_host')
.whereNot('smtp_host', '')
.select('id')
.first()
) ||
Boolean(
await this.db('mail_accounts')
.whereNotNull('smtp_host')
.whereNot('smtp_host', '')
.select('id')
.first()
);
features.whatsapp.configured =
features.whatsapp.configured &&
Boolean(
await this.db('whatsapp_configs')
.where({ enabled: formatBoolean(true) })
.whereNot('phone_number_id', '')
.whereNot('access_token', '')
.select('id')
.first()
);
const theme = settings.theme_config || {};
features.custom_css.configured = Boolean(
settings.general_custom_css ||
settings.general_public_site_custom_css ||
theme.customCss
);
// Read only the theme field, never event names, IDs, sizes, counts, or photos.
const themes = await this.db('events').distinct('color_theme');
const layouts = new Set();
for (const row of themes) {
const value = parse(row.color_theme);
const layout =
value && typeof value === 'object'
? value.galleryLayout || 'grid'
: 'grid';
layouts.add(LAYOUTS.includes(layout) ? layout : 'other');
if (value && typeof value === 'object' && value.customCss)
features.custom_css.configured = true;
}
// Applied CSS is already a capability in use; no visitor observation is
// needed. Remember its presence as a coarse lifetime marker after consent.
if (features.custom_css.configured) {
await this.markUsed(['custom_css']);
features.custom_css.used = true;
}
const now = new Date(this.now()).toISOString();
return {
picpeak_version: this.version,
report_date: now.slice(0, 10),
generated_at: now,
features,
gallery_layouts: [...layouts].sort()
};
}
async preview() {
const state = await this.state();
if (state.status !== 'active')
throw new ConflictError('Usage participation is not active');
return this.snapshot();
}
async command(action, payload) {
let receipt;
await this.locked(async (state) => {
if (state.status !== 'active')
throw new ConflictError('Usage participation is not active');
if (state.pending_packet)
throw new ConflictError('Retry the pending usage operation first');
if (!['feedback', 'vote', 'session'].includes(action))
throw new ValidationError('Invalid usage action');
const packet = makePacket(
state,
action,
Number(state.sequence) + 1,
payload
);
// Validate the complete packet before storing an un-sendable operation.
verifyEnvelope(
signPacket(
packet,
{
public_key: state.public_key,
private_key: this.decrypt(state.private_key_encrypted)
},
new Date(this.now())
),
this.now()
);
state.pending_packet = JSON.stringify(packet);
await this.db('product_usage_state')
.where({ id: 1 })
.update({ pending_packet: state.pending_packet });
receipt = await this.deliver(state);
});
const state = await this.status();
return {
delivered: Boolean(receipt),
queued: Boolean(state.pending_action),
receipt,
state
};
}
async preferences(value) {
if (
!value ||
Object.keys(value).some((k) => k !== 'name') ||
typeof value.name !== 'string' ||
value.name.length > 80
)
throw new ValidationError('Invalid feedback preferences');
const updated = await this.db('product_usage_state')
.where({ id: 1, status: 'active' })
.update({
feedback_preferences: JSON.stringify({ name: value.name.trim() })
});
if (!updated) throw new ConflictError('Usage participation is not active');
return this.status();
}
async export() {
const state = await this.state();
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(
'/api/participant/lookup',
{ installation_id: state.installation_id },
Infinity
);
}
}
module.exports = { UsageService, FLAG_MAP };
+155
View File
@@ -0,0 +1,155 @@
"use strict";
const crypto = require("node:crypto");
const Ajv = require("ajv");
const {
envelopeSchema,
FEATURE_KEYS,
LAYOUTS,
payloads,
} = require("./schema.cjs");
const validate = new Ajv({ allErrors: false, strict: true }).compile(
envelopeSchema,
);
const MAX_BYTES = 16384;
const MAX_AGE_MS = 5 * 60 * 1000;
class ProtocolError extends Error {
constructor(code, status = 400) {
super(code);
this.code = code;
this.status = status;
}
}
function canonical(value) {
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
if (value && typeof value === "object") {
return `{${Object.keys(value)
.sort()
.map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`)
.join(",")}}`;
}
return JSON.stringify(value);
}
const digest = (value) =>
crypto.createHash("sha256").update(value).digest("hex");
function publicKeyIdentity(publicKey) {
const bytes = Buffer.from(publicKey, "base64url");
if (bytes.toString("base64url") !== publicKey || bytes.length !== 44)
throw new ProtocolError("INVALID_KEY");
const key = crypto.createPublicKey({
key: bytes,
format: "der",
type: "spki",
});
if (key.asymmetricKeyType !== "ed25519")
throw new ProtocolError("INVALID_KEY");
return { key, id: digest(bytes) };
}
function generateIdentity() {
const keys = crypto.generateKeyPairSync("ed25519");
const public_key = keys.publicKey
.export({ format: "der", type: "spki" })
.toString("base64url");
return {
installation_id: publicKeyIdentity(public_key).id,
public_key,
private_key: keys.privateKey.export({ format: "pem", type: "pkcs8" }),
};
}
function makePacket(identity, action, sequence, payload) {
return {
schema_version: "usage.v1",
installation_id: identity.installation_id,
packet_id: crypto.randomUUID(),
action,
sequence,
payload,
};
}
function signPacket(packet, identity, now = new Date()) {
const signed = {
packet,
public_key: identity.public_key,
issued_at: now.toISOString(),
nonce: crypto.randomUUID(),
};
const signature = crypto
.sign(null, Buffer.from(canonical(signed)), identity.private_key)
.toString("base64url");
const envelope = { ...signed, signature };
if (
!validate(envelope) ||
Buffer.byteLength(JSON.stringify(envelope)) > MAX_BYTES
)
throw new ProtocolError("INVALID_PACKET");
return envelope;
}
function verifyEnvelope(envelope, now = Date.now()) {
if (
Buffer.byteLength(JSON.stringify(envelope) || "") > MAX_BYTES ||
!validate(envelope)
)
throw new ProtocolError("INVALID_PACKET");
const issued = Date.parse(envelope.issued_at);
if (
!Number.isFinite(issued) ||
new Date(issued).toISOString() !== envelope.issued_at ||
Math.abs(now - issued) > MAX_AGE_MS
) {
throw new ProtocolError("EXPIRED_SIGNATURE", 401);
}
let identity;
try {
identity = publicKeyIdentity(envelope.public_key);
} catch (_) {
throw new ProtocolError("INVALID_KEY", 401);
}
if (identity.id !== envelope.packet.installation_id)
throw new ProtocolError("IDENTITY_MISMATCH", 401);
const { signature, ...signed } = envelope;
const signatureBytes = Buffer.from(signature, "base64url");
if (
signatureBytes.toString("base64url") !== signature ||
!crypto.verify(
null,
Buffer.from(canonical(signed)),
identity.key,
signatureBytes,
)
)
throw new ProtocolError("INVALID_SIGNATURE", 401);
const { action, payload } = envelope.packet;
if (action === "report") {
const generated = Date.parse(payload.generated_at);
if (
!Number.isFinite(generated) ||
new Date(generated).toISOString() !== payload.generated_at ||
payload.report_date !== payload.generated_at.slice(0, 10) ||
generated > now + MAX_AGE_MS
)
throw new ProtocolError("INVALID_REPORT_DATE");
}
if (
action === "feedback" &&
payload.allow_marketing &&
(payload.kind !== "testimonial" || !payload.allow_public)
) {
throw new ProtocolError("INVALID_PUBLICATION_CONSENT");
}
return envelope.packet;
}
module.exports = {
canonical,
digest,
generateIdentity,
makePacket,
signPacket,
verifyEnvelope,
ProtocolError,
MAX_BYTES,
MAX_AGE_MS,
FEATURE_KEYS,
LAYOUTS,
envelopeSchema,
payloads,
};
+138
View File
@@ -0,0 +1,138 @@
"use strict";
// Vendored unchanged in PicPeak. Changing the wire contract requires a new
// schema version and matching conformance tests in both repositories.
const FEATURE_KEYS = [
"crm",
"crm_quotes",
"crm_invoices",
"crm_contracts",
"crm_projects",
"crm_calendar",
"crm_hours",
"customer_portal",
"accounting",
"workflows",
"newsletters",
"face_recognition",
"custom_css",
"oauth",
"smtp",
"whatsapp",
"backup",
"s3_storage",
"share_mounts",
];
const LAYOUTS = [
"grid",
"masonry",
"carousel",
"timeline",
"mosaic",
"gallery-premium",
"gallery-story",
"other",
];
const object = (properties, required = Object.keys(properties)) => ({
type: "object",
additionalProperties: false,
properties,
required,
});
const uuid = {
type: "string",
pattern:
"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
};
const hash = { type: "string", pattern: "^[0-9a-f]{64}$" };
const timestamp = {
type: "string",
pattern: "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$",
};
const text = (maxLength, minLength = 1) => ({
type: "string",
minLength,
maxLength,
});
const boolean = { type: "boolean" };
const features = object(
Object.fromEntries(
FEATURE_KEYS.map((key) => [
key,
object({ configured: boolean, used: boolean }),
]),
),
);
const report = object({
picpeak_version: {
type: "string",
maxLength: 48,
pattern: "^\\d+\\.\\d+\\.\\d+(?:-(?:alpha|beta|rc)\\.\\d+)?$",
},
report_date: { type: "string", pattern: "^\\d{4}-\\d{2}-\\d{2}$" },
generated_at: timestamp,
features,
gallery_layouts: {
type: "array",
uniqueItems: true,
maxItems: LAYOUTS.length,
items: { enum: LAYOUTS },
},
});
const feedback = object({
feedback_id: uuid,
kind: { enum: ["feedback", "feature_request", "testimonial"] },
title: text(120),
body: text(4000),
name: text(80, 0),
allow_public: boolean,
allow_marketing: boolean,
});
const payloads = {
register: object({ consent_version: { const: "usage-consent.v1" } }),
report,
delete: object({}),
feedback,
vote: object({ feedback_id: uuid, voted: boolean }),
session: object({}),
};
const packetBase = {
schema_version: { const: "usage.v1" },
installation_id: hash,
packet_id: uuid,
sequence: { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER },
};
const packetSchema = {
oneOf: Object.entries(payloads).map(([action, payload]) =>
object({
...packetBase,
action: { const: action },
payload,
}),
),
};
const envelopeSchema = {
$schema: "http://json-schema.org/draft-07/schema#",
$id: "https://usage.picpeak.app/schema/usage.v1.json",
title: "PicPeak usage.v1 signed envelope",
description:
"Only report.payload is automatic feature telemetry. Other actions are explicit participant operations. See /transparency for field semantics and retention.",
...object({
packet: packetSchema,
public_key: {
type: "string",
minLength: 59,
maxLength: 59,
pattern: "^[A-Za-z0-9_-]+$",
},
issued_at: timestamp,
nonce: uuid,
signature: {
type: "string",
minLength: 86,
maxLength: 86,
pattern: "^[A-Za-z0-9_-]+$",
},
}),
};
module.exports = { FEATURE_KEYS, LAYOUTS, envelopeSchema, payloads };