feat: expand opt-in capability coverage with versioned consent
This commit is contained in:
@@ -51,6 +51,7 @@ maybe('product usage on Postgres', () => {
|
||||
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 require('../../migrations/core/205_product_usage_consent_version').up(db);
|
||||
|
||||
await db.schema.createTable('app_settings', (t) => {
|
||||
t.string('setting_key').primary(); t.text('setting_value'); t.string('setting_type');
|
||||
@@ -112,6 +113,7 @@ maybe('product usage on Postgres', () => {
|
||||
expect(cols.cancel_requested).toBeUndefined(); // dropped by 203
|
||||
expect(cols.sequence).toBeDefined();
|
||||
expect(cols.privacy_receipts).toBeDefined();
|
||||
expect(cols.consent_version).toBeDefined();
|
||||
});
|
||||
|
||||
it('reruns the receipt migration safely and scrubs legacy plaintext sessions', async () => {
|
||||
@@ -125,6 +127,27 @@ maybe('product usage on Postgres', () => {
|
||||
expect(JSON.parse(row.last_receipt)).toEqual({ status: 'accepted' });
|
||||
});
|
||||
|
||||
it('migration preserves v1 consent and v2 snapshot works with PostgreSQL booleans and optional modules', async () => {
|
||||
const migration = require('../../migrations/core/205_product_usage_consent_version');
|
||||
await migration.up(db); await migration.up(db);
|
||||
const svc = service();
|
||||
await db('product_usage_state').where({ id: 1 }).update({ status: 'active' });
|
||||
await svc.markUsed(['video_uploads']);
|
||||
expect(await db('product_usage_markers').pluck('feature')).toEqual([]);
|
||||
expect((await svc.status()).schema_version).toBe('usage.v1');
|
||||
await db('product_usage_state').where({ id: 1 }).update({ consent_version: 'usage-consent.v2' });
|
||||
await db('feature_flags').insert({ key: 'quotes', value: true });
|
||||
await db('app_settings').insert({ setting_key: 'general_allowed_file_types', setting_value: '"dng,mp4"' });
|
||||
await svc.markUsed(['video_uploads', 'gallery_downloads']);
|
||||
const report = await svc.snapshot();
|
||||
expect(Object.keys(report.features)).toHaveLength(73);
|
||||
expect(report.features.video_uploads).toEqual({ configured: true, used: true });
|
||||
expect(report.features.camera_raw_uploads).toEqual({ configured: true, used: false });
|
||||
expect(report.features.gallery_downloads).toEqual({ configured: false });
|
||||
expect(report.features.crm.configured).toBe(true);
|
||||
expect(report.features.api_integration.configured).toBe(false);
|
||||
});
|
||||
|
||||
it('reads bigint cancel_seq correctly even though pg returns it as a string', async () => {
|
||||
await db('product_usage_state').where({ id: 1 }).update({ cancel_seq: 5 });
|
||||
const row = await db('product_usage_state').where({ id: 1 }).first();
|
||||
|
||||
@@ -41,6 +41,7 @@ jest.mock('../../src/services/productUsageService', () =>
|
||||
);
|
||||
const service = require('../../src/services/productUsageService');
|
||||
const { productUsage } = require('../../src/middleware/productUsage');
|
||||
const { productUsageApi } = require('../../src/middleware/productUsage');
|
||||
const SECRET = 'usage-auth-test-secret-not-a-live-credential';
|
||||
const token = (type, id = 1) =>
|
||||
jwt.sign({ type, id }, SECRET, {
|
||||
@@ -102,10 +103,27 @@ beforeAll(async () => {
|
||||
afterAll(() => mockDb.destroy());
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
test('scoped API use records only its fixed v2 capability and never triggers a report', () => {
|
||||
const simulate = (admin, apiToken, statusCode) => {
|
||||
const res = new (require('events').EventEmitter)(); res.statusCode = statusCode;
|
||||
productUsageApi({ admin, apiToken, body: { user: '[email protected]' } }, res, () => {});
|
||||
res.emit('finish');
|
||||
};
|
||||
simulate(null, { id: 99 }, 200);
|
||||
simulate({ id: 42 }, null, 200);
|
||||
simulate({ id: 42 }, { id: 99 }, 403);
|
||||
expect(service.markUsed).not.toHaveBeenCalled();
|
||||
simulate({ id: 42 }, { id: 99 }, 200);
|
||||
expect(service.markUsed).toHaveBeenCalledWith(['api_integration'], { legacyFeatures: [] });
|
||||
expect(service.tick).not.toHaveBeenCalled();
|
||||
expect(JSON.stringify(service.markUsed.mock.calls)).not.toMatch(/PRIVATE|42|99/);
|
||||
});
|
||||
|
||||
const ROUTES = [
|
||||
['get', '/'],
|
||||
['post', '/activity'],
|
||||
['post', '/enable'],
|
||||
['post', '/consent'],
|
||||
['post', '/disable'],
|
||||
['post', '/retry'],
|
||||
['post', '/dismiss'],
|
||||
@@ -162,8 +180,9 @@ test('public/gallery paths and failed/unauthenticated admin operations never set
|
||||
const { EventEmitter } = require('events');
|
||||
const simulate = (path, admin, statusCode) => {
|
||||
const res = new EventEmitter();
|
||||
res.locals = {};
|
||||
res.statusCode = statusCode;
|
||||
productUsage({ path, admin }, res, () => {});
|
||||
productUsage({ path, method: 'POST', admin }, res, () => {});
|
||||
res.emit('finish');
|
||||
};
|
||||
simulate('/gallery/example', null, 200);
|
||||
@@ -180,6 +199,15 @@ test('public/gallery paths and failed/unauthenticated admin operations never set
|
||||
expect(JSON.stringify(service.markUsed.mock.calls)).not.toContain('42');
|
||||
});
|
||||
|
||||
test('consent upgrade accepts exactly the explicit v2 choice, never extra fields', async () => {
|
||||
for (const data of [{}, { consent_version: 'usage-consent.v1' }, { consent_version: 'usage-consent.v2', user: 'PRIVATE' }])
|
||||
await request(app).post('/api/admin/usage/consent').set('Authorization', `Bearer ${token('admin')}`).send(data).expect(400);
|
||||
expect(service.command).not.toHaveBeenCalled();
|
||||
await request(app).post('/api/admin/usage/consent').set('Authorization', `Bearer ${token('admin')}`)
|
||||
.send({ consent_version: 'usage-consent.v2' }).expect(200);
|
||||
expect(service.command).toHaveBeenCalledWith('consent', { consent_version: 'usage-consent.v2' });
|
||||
});
|
||||
|
||||
test('only a backup that writes to the configured destination flags S3', () => {
|
||||
// /database-backup/* and /backup/picpeak/export produce a local file, so
|
||||
// they must not imply S3 use just because S3 is the configured destination.
|
||||
@@ -187,8 +215,9 @@ test('only a backup that writes to the configured destination flags S3', () => {
|
||||
const simulate = (pathname) => {
|
||||
service.markUsed.mockClear();
|
||||
const res = new (require('events').EventEmitter)();
|
||||
res.locals = {};
|
||||
res.statusCode = 200;
|
||||
productUsage({ path: pathname, admin: { id: 1 } }, res, () => {});
|
||||
productUsage({ path: pathname, method: pathname.endsWith('/export') ? 'GET' : 'POST', admin: { id: 1 } }, res, () => {});
|
||||
res.emit('finish');
|
||||
seen.push([pathname, service.markUsed.mock.calls[0]?.[1]?.destinationBackup]);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const catalog = require('../../src/usage/features.v2.json');
|
||||
const inventory = require('../../../docs/usage-coverage.v2.json');
|
||||
const protocol = require('../../src/usage/schema.cjs');
|
||||
const { RULES_V2, capabilityKeys } = require('../../src/usage/capabilityRules');
|
||||
const { acceptedUpload, capabilityEvidence } = require('../../src/usage/capabilityEvidence');
|
||||
|
||||
test('every route family and literal route declaration has an explicit privacy decision', () => {
|
||||
const root = path.resolve(__dirname, '../../src/routes');
|
||||
const actual = {};
|
||||
function walk(dir) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.name === '__tests__') continue;
|
||||
const file = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(file);
|
||||
else if (entry.name.endsWith('.js')) {
|
||||
const source = fs.readFileSync(file, 'utf8');
|
||||
actual[path.relative(root, file)] = [...source.matchAll(/router\.(get|post|put|patch|delete)\(\s*(['"])([^'"]+)\2/g)]
|
||||
.map((m) => `${m[1].toUpperCase()} ${m[3]}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(root);
|
||||
expect(Object.keys(inventory.route_families).sort()).toEqual(Object.keys(actual).sort());
|
||||
for (const [file, decision] of Object.entries(inventory.route_families)) {
|
||||
expect(decision.reason.length).toBeGreaterThan(30);
|
||||
expect(decision.route_signatures).toEqual(actual[file]);
|
||||
for (const signal of decision.signals) expect(catalog.features[signal]).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
test('all flags and catalog capabilities have a documented decision', () => {
|
||||
const source = fs.readFileSync(path.resolve(__dirname, '../../src/routes/adminFeatureFlags.js'), 'utf8');
|
||||
const array = source.match(/const KNOWN_FLAGS = \[([\s\S]*?)\];/)[1].replace(/\/\/[^\n]*/g, '');
|
||||
const flags = [...array.matchAll(/'([^']+)'/g)].map((m) => m[1]);
|
||||
expect(Object.keys(inventory.feature_flags).sort()).toEqual(flags.sort());
|
||||
for (const key of protocol.FEATURE_KEYS)
|
||||
expect(Object.values(inventory.route_families).some((family) => family.signals.includes(key))).toBe(true);
|
||||
expect(inventory.configuration_only.sort()).toEqual(protocol.FEATURE_KEYS.filter((key) => !protocol.observesUse(key)).sort());
|
||||
});
|
||||
|
||||
test('all current settings tabs have an explicit scope decision', () => {
|
||||
const source = fs.readFileSync(path.resolve(__dirname, '../../../frontend/src/pages/admin/SettingsPage.tsx'), 'utf8');
|
||||
const union = source.match(/type TabType =([\s\S]*?);/)[1].replace(/\/\/[^\n]*/g, '');
|
||||
const tabs = [...union.matchAll(/'([^']+)'/g)].map((m) => m[1]);
|
||||
expect(Object.keys(inventory.settings_tabs).sort()).toEqual(tabs.sort());
|
||||
for (const entry of Object.values(inventory.settings_tabs)) {
|
||||
expect(entry.reason.length).toBeGreaterThan(20);
|
||||
for (const key of entry.signals) expect(catalog.features[key]).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
test('v1 wire validation is immutable; catalog, UI and translated descriptions agree', () => {
|
||||
expect(crypto.createHash('sha256').update(JSON.stringify(protocol.envelopeSchemas['usage.v1'].properties)).digest('hex'))
|
||||
.toBe('cc8d0a865d21e36d2b24d23ca6aa8dd8d48000cb17aef83996786f70755bc922');
|
||||
expect(protocol.FEATURE_KEYS).toHaveLength(73);
|
||||
expect(protocol.LEGACY_FEATURE_KEYS).toHaveLength(19);
|
||||
expect(inventory.configuration_only).toHaveLength(17);
|
||||
const frontend = path.resolve(__dirname, '../../../frontend');
|
||||
expect(JSON.parse(fs.readFileSync(path.join(frontend, 'src/features/settings/usageFeatures.v2.json')))).toEqual(catalog);
|
||||
for (const lang of ['en', 'de']) {
|
||||
const translated = JSON.parse(fs.readFileSync(path.join(frontend, `src/i18n/locales/${lang}.json`))).productUsage.catalog;
|
||||
for (const [key, value] of Object.entries(catalog.features)) {
|
||||
expect(translated[key]).toEqual({ name: value.name[lang], configured: value.configured[lang], ...(value.used ? { used: value.used[lang] } : {}) });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('every used field has either a fixed route rule or explicit trusted success evidence', () => {
|
||||
const explicit = ['custom_css', 'oauth', 'smtp', 'email_webhook', 'whatsapp', 'incoming_mail',
|
||||
'video_uploads', 'camera_raw_uploads', 's3_storage', 's3_photo_storage', 's3_backups', 'api_integration'];
|
||||
const covered = new Set([...explicit, ...RULES_V2.flatMap(([, , keys]) => keys)]);
|
||||
expect(protocol.FEATURE_KEYS.filter(protocol.observesUse).filter((key) => !covered.has(key))).toEqual([]);
|
||||
for (const key of covered) expect(protocol.observesUse(key)).toBe(true);
|
||||
});
|
||||
|
||||
test.each([
|
||||
['POST', '/events', 'galleries'], ['POST', '/events/123/publish', 'galleries'],
|
||||
['POST', '/photos/repair-dimensions', 'photo_processing'], ['GET', '/events/123/photos/456/download', 'photo_exports'],
|
||||
['PUT', '/events/123/slideshow', 'slideshow'], ['POST', '/expenses/inbound', 'accounting_incoming_invoices'],
|
||||
['POST', '/expenses', 'accounting_expenses'], ['GET', '/tax-report/csv', 'accounting_tax_report'],
|
||||
['POST', '/deals/123/installment-plan', 'crm_installments'], ['GET', '/ledger/export', 'accounting_ledger'],
|
||||
['POST', '/quotes/presets', 'document_templates'], ['PUT', '/cms/pages/home', 'cms'],
|
||||
['POST', '/webhooks/123/test', 'webhooks'], ['POST', '/webhooks/123/deliveries/456/replay', 'webhooks'],
|
||||
['POST', '/email/send', 'messaging'], ['PUT', '/feedback/feedback/123/approve', 'feedback_moderation'],
|
||||
['GET', '/events/123/guests/export-all', 'guest_management'], ['POST', '/backup/picpeak/import', 'portable_backup'],
|
||||
['PUT', '/roles/123', 'admin_management'], ['POST', '/newsletters/123/queue', 'newsletters']
|
||||
])('fixed allowlist recognizes %s %s', (method, url, expected) => {
|
||||
expect(capabilityKeys(method, url)).toContain(expected);
|
||||
expect(JSON.stringify(capabilityKeys(method, url))).not.toContain('123');
|
||||
});
|
||||
|
||||
test.each([
|
||||
['GET', '/events/faces/health'], ['GET', '/photos/repair-dimensions/status'],
|
||||
['POST', '/events/123/validate-rename'], ['POST', '/photos/123/chunked-upload/init'],
|
||||
['POST', '/photos/123/chunked-upload/456/chunk/0'], ['GET', '/dashboard/health'],
|
||||
['GET', '/customers'], ['GET', '/email/queue'], ['POST', '/email/flush-queue'],
|
||||
['POST', '/newsletters/123/recipients/resolve'], ['POST', '/newsletters/123/preview'],
|
||||
['POST', '/users/123/reset-password'], ['PUT', '/settings/security'],
|
||||
['POST', '/gallery/a/feedback'], ['POST', '/public/newsletter/unsubscribe'],
|
||||
['POST', '/customer/quotes/123/accept'], ['POST', '/usage/consent']
|
||||
])('no v2 observation for excluded %s %s', (method, url) => expect(capabilityKeys(method, url)).toEqual([]));
|
||||
|
||||
test('trusted upload evidence retains only constant keys and configuration-only use cannot be recorded', () => {
|
||||
const res = { locals: {} };
|
||||
acceptedUpload(res, { video: true, raw: true, s3: true });
|
||||
capabilityEvidence(res, '[email protected]', 'gallery_feedback_likes');
|
||||
expect(res.locals.productUsageFeatures.sort()).toEqual(['photo_management', 'video_uploads', 'camera_raw_uploads', 's3_storage', 's3_photo_storage'].sort());
|
||||
});
|
||||
@@ -19,7 +19,7 @@ const SECRET = 'z'.repeat(48);
|
||||
// during signing, so the packet would never reach the collector for reasons
|
||||
// unrelated to what the test is checking.
|
||||
function validReport() {
|
||||
const { FEATURE_KEYS } = require('../../src/usage/protocol.cjs');
|
||||
const { LEGACY_FEATURE_KEYS: FEATURE_KEYS } = require('../../src/usage/protocol.cjs');
|
||||
return {
|
||||
picpeak_version: '3.0.0',
|
||||
report_date: '2026-09-05',
|
||||
@@ -40,6 +40,7 @@ async function bootDb() {
|
||||
await db.schema.createTable('product_usage_state', (t) => {
|
||||
t.integer('id').primary();
|
||||
t.string('status', 30).notNullable().defaultTo('disabled');
|
||||
t.string('consent_version', 40).notNullable().defaultTo('usage-consent.v1');
|
||||
t.boolean('notice_dismissed').notNullable().defaultTo(false);
|
||||
t.string('installation_id', 64);
|
||||
t.string('public_key', 59);
|
||||
@@ -173,7 +174,8 @@ describe('withdrawal during an in-flight activation', () => {
|
||||
{ installation_id: identity.installation_id },
|
||||
'report',
|
||||
2,
|
||||
validReport()
|
||||
validReport(),
|
||||
'usage.v1'
|
||||
)
|
||||
),
|
||||
});
|
||||
|
||||
@@ -23,6 +23,7 @@ async function bootDb() {
|
||||
await db.schema.createTable('product_usage_state', (t) => {
|
||||
t.integer('id').primary();
|
||||
t.string('status', 30).notNullable().defaultTo('disabled');
|
||||
t.string('consent_version', 40).notNullable().defaultTo('usage-consent.v1');
|
||||
t.boolean('notice_dismissed').notNullable().defaultTo(false);
|
||||
t.string('installation_id', 64);
|
||||
t.string('public_key', 59);
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
*/
|
||||
const knex = require('knex');
|
||||
const { UsageService } = require('../../src/usage/UsageService');
|
||||
const { FEATURE_KEYS, CATALOG, generateIdentity, makePacket, signPacket, verifyEnvelope } = require('../../src/usage/protocol.cjs');
|
||||
|
||||
async function bootDb() {
|
||||
const db = knex({
|
||||
@@ -20,6 +21,7 @@ async function bootDb() {
|
||||
await db.schema.createTable('product_usage_state', (t) => {
|
||||
t.integer('id').primary();
|
||||
t.string('status', 30).notNullable().defaultTo('disabled');
|
||||
t.string('consent_version', 40).notNullable().defaultTo('usage-consent.v1');
|
||||
t.boolean('notice_dismissed').notNullable().defaultTo(false);
|
||||
t.string('installation_id', 64);
|
||||
t.string('public_key', 59);
|
||||
@@ -193,3 +195,110 @@ describe('S3 use is only implied by backups that write to the destination', () =
|
||||
expect(await db('product_usage_markers').pluck('feature')).toEqual(['backup']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('v2 technical configuration and privacy boundaries', () => {
|
||||
let db;
|
||||
let savedEnv;
|
||||
beforeEach(() => { savedEnv = { ...process.env }; });
|
||||
afterEach(async () => { if (db) await db.destroy(); db = null; process.env = savedEnv; });
|
||||
async function expandedDb() {
|
||||
db = await bootDb();
|
||||
await db('product_usage_state').where({ id: 1 }).update({ status: 'active', consent_version: 'usage-consent.v2' });
|
||||
await db.schema.alterTable('events', (t) => {
|
||||
for (const column of ['allow_user_uploads', 'allow_downloads', 'client_access_enabled', 'watermark_downloads', 'reveal_mode', 'download_resolution_picker_enabled', 'disable_right_click', 'enable_devtools_protection', 'use_canvas_rendering']) t.boolean(column);
|
||||
t.string('protection_level'); t.timestamp('expires_at'); t.string('event_name'); t.string('customer_email');
|
||||
});
|
||||
for (const table of ['email_configs', 'mail_accounts']) await db.schema.alterTable(table, (t) => {
|
||||
t.boolean('enabled'); t.string('imap_host'); t.string('imap_user'); t.string('imap_pass');
|
||||
});
|
||||
await db.schema.createTable('event_feedback_settings', (t) => {
|
||||
t.increments('id'); t.boolean('feedback_enabled'); t.string('identity_mode');
|
||||
for (const col of ['allow_likes', 'allow_ratings', 'allow_comments', 'allow_favorites', 'allow_reactions', 'allow_color_labels']) t.boolean(col);
|
||||
});
|
||||
await db.schema.createTable('api_tokens', (t) => { t.increments('id'); t.timestamp('revoked_at'); t.timestamp('expires_at'); t.string('token_hash'); });
|
||||
await db.schema.createTable('webhooks', (t) => { t.increments('id'); t.boolean('active'); t.string('url'); t.string('secret'); });
|
||||
return service(db, { now: () => Date.parse('2026-09-06T12:00:00.000Z'), version: '3.124.1-beta.0' });
|
||||
}
|
||||
|
||||
it('produces all 73 closed booleans, never exposing sensitive values or configuration-only used', async () => {
|
||||
const client = await expandedDb();
|
||||
const flags = [...new Set(Object.values(CATALOG.features).map((f) => f.flag).filter(Boolean)), 'incomingMail', 'whatsapp'];
|
||||
await db('feature_flags').insert([...new Set(flags)].map((key) => ({ key, value: true })));
|
||||
const settings = {
|
||||
general_allowed_file_types: 'jpg,dng,mp4', general_public_site_enabled: true,
|
||||
database_backup_enabled: true, backup_destination_type: 's3', backup_s3_bucket: 'PRIVATE-bucket',
|
||||
oidc_enabled: true, oidc_issuer_url: 'https://PRIVATE.example.test', oidc_client_id: 'PRIVATE-client',
|
||||
general_custom_css: '.PRIVATE { color:red; }'
|
||||
};
|
||||
await db('app_settings').insert(Object.entries(settings).map(([setting_key, value]) => ({ setting_key, setting_value: JSON.stringify(value) })));
|
||||
await db('events').insert({
|
||||
event_name: 'PRIVATE PERSON', customer_email: '[email protected]', external_path: '/PRIVATE/path',
|
||||
color_theme: JSON.stringify({ galleryLayout: 'gallery-story', privateName: 'PRIVATE' }),
|
||||
allow_user_uploads: true, allow_downloads: true, client_access_enabled: true, watermark_downloads: true,
|
||||
reveal_mode: true, download_resolution_picker_enabled: true, disable_right_click: true,
|
||||
expires_at: '2028-01-01T00:00:00.000Z'
|
||||
});
|
||||
await db('event_feedback_settings').insert({ feedback_enabled: true, identity_mode: 'guest',
|
||||
allow_likes: true, allow_ratings: true, allow_comments: true, allow_favorites: true, allow_reactions: true, allow_color_labels: true });
|
||||
await db('email_configs').insert({ smtp_host: 'PRIVATE-host', imap_host: 'PRIVATE-host', imap_user: 'PRIVATE-user', imap_pass: 'PRIVATE-secret' });
|
||||
await db('whatsapp_configs').insert({ enabled: true, phone_number_id: 'PRIVATE-phone', access_token: 'PRIVATE-token' });
|
||||
await db('api_tokens').insert({ token_hash: 'PRIVATE-token', expires_at: '2028-01-01T00:00:00.000Z' });
|
||||
await db('webhooks').insert({ active: true, url: 'https://PRIVATE.example.test', secret: 'PRIVATE-secret' });
|
||||
Object.assign(process.env, { STORAGE_BACKEND: 's3', STORAGE_S3_BUCKET: 'PRIVATE', STORAGE_S3_ACCESS_KEY: 'PRIVATE', STORAGE_S3_SECRET_KEY: 'PRIVATE', EMAIL_WEBHOOK_URL: 'https://PRIVATE.example.test', EMAIL_WEBHOOK_SECRET: 'PRIVATE' });
|
||||
delete process.env.PICPEAK_SINGLE_CONTAINER;
|
||||
await client.markUsed([...FEATURE_KEYS, '[email protected]']);
|
||||
const report = await client.snapshot();
|
||||
expect(Object.keys(report.features)).toEqual(FEATURE_KEYS);
|
||||
for (const [key, definition] of Object.entries(CATALOG.features)) {
|
||||
expect(report.features[key].configured).toBe(true);
|
||||
if (definition.used) expect(report.features[key].used).toBe(true);
|
||||
else expect(report.features[key]).toEqual({ configured: true });
|
||||
}
|
||||
expect(await db('product_usage_markers').pluck('feature')).toHaveLength(56);
|
||||
expect(JSON.stringify(report)).not.toContain('PRIVATE');
|
||||
const identity = generateIdentity();
|
||||
const envelope = signPacket(makePacket(identity, 'report', 1, report), identity, new Date(report.generated_at));
|
||||
expect(verifyEnvelope(envelope, Date.parse(report.generated_at))).toEqual(envelope.packet);
|
||||
});
|
||||
|
||||
it('applies parent/AIO gates and does not confuse disabled or expired config with availability', async () => {
|
||||
const client = await expandedDb();
|
||||
process.env.PICPEAK_SINGLE_CONTAINER = 'yes';
|
||||
await db('feature_flags').insert(['bills', 'incomingInvoices', 'expenses', 'taxReport', 'faces', 'incomingMail'].map((key) => ({ key, value: true })));
|
||||
await db('api_tokens').insert([
|
||||
{ revoked_at: '2026-01-01', expires_at: null },
|
||||
{ revoked_at: null, expires_at: '2026-01-01' }
|
||||
]);
|
||||
await db('webhooks').insert({ active: false });
|
||||
await db('mail_accounts').insert({ enabled: false, imap_host: 'PRIVATE', imap_user: 'PRIVATE', imap_pass: 'PRIVATE' });
|
||||
await db('event_feedback_settings').insert({ feedback_enabled: false, identity_mode: 'guest', allow_likes: true });
|
||||
await db('events').insert({ allow_user_uploads: false, reveal_mode: true });
|
||||
const report = await client.snapshot();
|
||||
for (const key of ['crm_invoices', 'accounting_incoming_invoices', 'accounting_expenses', 'accounting_tax_report', 'face_recognition', 'api_integration', 'webhooks', 'incoming_mail', 'gallery_feedback_likes', 'gallery_guest_accounts', 'gallery_reveal']) expect(report.features[key].configured).toBe(false);
|
||||
expect(report.features.galleries).toEqual({ configured: true, used: false });
|
||||
expect(report.features.admin_management.configured).toBe(true);
|
||||
expect(report.features.analytics_dashboard.configured).toBe(true);
|
||||
});
|
||||
|
||||
it('handles missing optional tables, global protection defaults and durable consent boundaries', async () => {
|
||||
db = await bootDb();
|
||||
const client = service(db);
|
||||
await db('app_settings').insert({ setting_key: 'default_protection_level', setting_value: '"enhanced"' });
|
||||
await client.markUsed(FEATURE_KEYS);
|
||||
expect(await db('product_usage_markers').pluck('feature')).toEqual([]);
|
||||
await db('product_usage_state').update({ status: 'active' });
|
||||
await client.markUsed(['video_uploads', 'api_integration']);
|
||||
expect(await db('product_usage_markers').pluck('feature')).toEqual([]);
|
||||
expect(Object.keys((await client.snapshot()).features)).toHaveLength(19);
|
||||
await db('product_usage_state').update({ consent_version: 'usage-consent.v2' });
|
||||
const report = await client.snapshot();
|
||||
expect(report.features.gallery_image_protection).toEqual({ configured: true });
|
||||
expect(report.features.api_integration).toEqual({ configured: false, used: false });
|
||||
expect(report.features.document_templates).toEqual({ configured: false, used: false });
|
||||
await client.markUsed(['video_uploads', 'gallery_downloads']);
|
||||
expect(await db('product_usage_markers').pluck('feature')).toEqual(['video_uploads']);
|
||||
await db('product_usage_state').update({ status: 'deletion_pending' });
|
||||
await client.markUsed(['api_integration']);
|
||||
expect(await db('product_usage_markers').pluck('feature')).toEqual(['video_uploads']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// Existing participants retain their v1 consent and v1 allowlist. New fields
|
||||
// require a separate explicit, signed upgrade; migrations never opt anyone in.
|
||||
exports.up = async function (knex) {
|
||||
if (
|
||||
(await knex.schema.hasTable('product_usage_state')) &&
|
||||
!(await knex.schema.hasColumn('product_usage_state', 'consent_version'))
|
||||
) {
|
||||
await knex.schema.alterTable('product_usage_state', (t) => {
|
||||
t.string('consent_version', 40).notNullable().defaultTo('usage-consent.v1');
|
||||
});
|
||||
}
|
||||
};
|
||||
exports.down = async function (knex) {
|
||||
if (
|
||||
(await knex.schema.hasTable('product_usage_state')) &&
|
||||
(await knex.schema.hasColumn('product_usage_state', 'consent_version'))
|
||||
) {
|
||||
await knex.schema.alterTable('product_usage_state', (t) => t.dropColumn('consent_version'));
|
||||
}
|
||||
};
|
||||
+1
-1
@@ -935,7 +935,7 @@ app.use('/api/admin/api-tokens', require('./src/routes/adminApiTokens'));
|
||||
app.use('/api/admin/webhooks', require('./src/routes/adminWebhooks'));
|
||||
// Public v1 API for n8n / external integrations (#322). Mounted under
|
||||
// /api/v1; auth handled per-route via apiTokenAuth (Bearer tokens).
|
||||
app.use('/api/v1', require('./src/routes/v1/events'));
|
||||
app.use('/api/v1', require('./src/middleware/productUsage').productUsageApi, require('./src/routes/v1/events'));
|
||||
|
||||
// Swagger UI for the v1 API. Admin-gated since it lists endpoint shapes
|
||||
// that should not be enumerable to anonymous users (a common reduce-info-leak hardening).
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// identifiers, paths, timing, or counts are retained or sent.
|
||||
const service = require('../services/productUsageService');
|
||||
const logger = require('../utils/logger');
|
||||
const { capabilityKeys } = require('../usage/capabilityRules');
|
||||
// Mirrors emailWebhookTransport: the webhook is in play only when both are
|
||||
// set, which is when adminEmail routes the test send through it.
|
||||
const webhookTransportConfigured = () =>
|
||||
@@ -59,13 +60,28 @@ function productUsage(req, res, next) {
|
||||
/^\/(?:photos|events)\/[^/]+\/upload(?:\/|$)/.test(pathname)
|
||||
)
|
||||
features.push('s3_storage');
|
||||
if (features.length)
|
||||
const expanded = [...new Set([
|
||||
...capabilityKeys(req.method, pathname),
|
||||
...(res.locals.productUsageFeatures || [])
|
||||
])];
|
||||
if (features.length || expanded.length)
|
||||
service
|
||||
.markUsed(features, {
|
||||
.markUsed(expanded, {
|
||||
legacyFeatures: features,
|
||||
destinationBackup: DESTINATION_BACKUP.test(pathname)
|
||||
})
|
||||
.catch(() => logger.warn('Product usage marker could not be recorded'));
|
||||
});
|
||||
next();
|
||||
}
|
||||
module.exports = { productUsage, RULES, DESTINATION_BACKUP };
|
||||
// Integration calls can record one general capability, but never trigger the
|
||||
// daily sender. Public/customer/gallery routes do not mount this middleware.
|
||||
function productUsageApi(req, res, next) {
|
||||
res.once('finish', () => {
|
||||
if (!req.admin?.id || !req.apiToken || res.statusCode < 200 || res.statusCode >= 300) return;
|
||||
service.markUsed(['api_integration'], { legacyFeatures: [] })
|
||||
.catch(() => logger.warn('Product usage API marker could not be recorded'));
|
||||
});
|
||||
next();
|
||||
}
|
||||
module.exports = { productUsage, productUsageApi, RULES, DESTINATION_BACKUP };
|
||||
|
||||
@@ -795,6 +795,8 @@ router.post('/s3/test-upload', adminAuth, requirePermission('backup.create'), as
|
||||
|
||||
// Test deletion
|
||||
await s3Adapter.delete(testKey);
|
||||
|
||||
if (contentMatch) require('../usage/capabilityEvidence').capabilityEvidence(res, 's3_storage', 's3_backups');
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const express = require('express');
|
||||
const { capabilityEvidence } = require('../usage/capabilityEvidence');
|
||||
const nodemailer = require('nodemailer');
|
||||
const { body, query, validationResult } = require('express-validator');
|
||||
const { db, logActivity } = require('../database/db');
|
||||
@@ -221,6 +222,7 @@ router.post('/incoming-config/test', adminAuth, requirePermission('email.view'),
|
||||
if (result && result.ok === false) {
|
||||
return res.status(400).json({ error: 'Incoming mail is not configured yet — enter host, username and password first.' });
|
||||
}
|
||||
if (result?.ok) capabilityEvidence(res, 'incoming_mail');
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logger.error('IMAP connection test error:', error);
|
||||
@@ -234,7 +236,10 @@ router.post('/incoming-config/roundtrip', adminAuth, requirePermission('email.se
|
||||
try {
|
||||
const emailIntakeService = require('../services/emailIntakeService');
|
||||
const result = await emailIntakeService.roundTripTest();
|
||||
if (result.ok) return res.json(result);
|
||||
if (result.ok) {
|
||||
capabilityEvidence(res, 'incoming_mail', 'smtp');
|
||||
return res.json(result);
|
||||
}
|
||||
const map = {
|
||||
smtp_unconfigured: 'Configure and save the outgoing SMTP settings first.',
|
||||
imap_unconfigured: 'Configure and save the incoming IMAP settings first.',
|
||||
@@ -257,6 +262,7 @@ router.post('/incoming-config/poll', adminAuth, requirePermission('email.view'),
|
||||
try {
|
||||
const emailIntakeService = require('../services/emailIntakeService');
|
||||
const result = await emailIntakeService.pollOnce();
|
||||
if (result && !result.skipped) capabilityEvidence(res, 'incoming_mail');
|
||||
res.json(result); // { processed } or { skipped: 'disabled'|'unconfigured'|'busy' }
|
||||
} catch (error) {
|
||||
logger.error('Manual poll error:', error);
|
||||
@@ -456,6 +462,7 @@ router.post('/accounts/test', adminAuth, messagingGate, requirePermission('email
|
||||
host: b.imap_host, port: b.imap_port, secure: b.imap_secure,
|
||||
user: b.imap_user, pass, folder: b.imap_folder || 'INBOX',
|
||||
});
|
||||
if (result?.ok) capabilityEvidence(res, 'incoming_mail');
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(422).json({ ok: false, error: `Mailbox test failed (${error.message}).` });
|
||||
@@ -511,6 +518,7 @@ router.post('/test', adminAuth, requirePermission('email.send'), async (req, res
|
||||
details: webhookError.message,
|
||||
});
|
||||
}
|
||||
capabilityEvidence(res, 'email_webhook');
|
||||
return res.json({ message: 'Test email sent successfully' });
|
||||
}
|
||||
|
||||
@@ -587,6 +595,7 @@ router.post('/test', adminAuth, requirePermission('email.send'), async (req, res
|
||||
+ await buildSignatureTextFor('en')
|
||||
});
|
||||
|
||||
capabilityEvidence(res, 'smtp');
|
||||
res.json({ message: 'Test email sent successfully' });
|
||||
} catch (error) {
|
||||
logger.error('Test email error:', error);
|
||||
@@ -847,6 +856,8 @@ router.post('/send', adminAuth, messagingGate, requirePermission('email.send'),
|
||||
|
||||
const emailProcessor = require('../services/emailProcessor');
|
||||
const result = await emailProcessor.sendRawEmail({ to, cc, subject, html, accountKey });
|
||||
if (result.transport === 'webhook') capabilityEvidence(res, 'email_webhook');
|
||||
if (result.transport === 'smtp') capabilityEvidence(res, 'smtp');
|
||||
|
||||
await db('email_queue').insert({
|
||||
recipient_email: to,
|
||||
@@ -1259,4 +1270,4 @@ router.post('/templates/:key/preview', adminAuth, requirePermission('email.view'
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports = router;
|
||||
|
||||
@@ -7,6 +7,7 @@ const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { ensureThumbnail } = require('../services/imageProcessor');
|
||||
const { isVideoMimeType } = require('../services/videoProcessor');
|
||||
const { acceptedUpload } = require('../usage/capabilityEvidence');
|
||||
const { generatePhotoFilename, buildContentDisposition } = require('../utils/filenameSanitizer');
|
||||
const {
|
||||
getUseOriginalFilenames,
|
||||
@@ -357,6 +358,11 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), r
|
||||
event,
|
||||
});
|
||||
if (result.success) {
|
||||
acceptedUpload(res, {
|
||||
video: isVideoMimeType(file.mimetype),
|
||||
raw: path.extname(file.originalname).toLowerCase() === '.dng',
|
||||
s3: process.env.STORAGE_BACKEND === 's3'
|
||||
});
|
||||
replacedPhotos.push({
|
||||
id: result.photo.id,
|
||||
filename: result.photo.filename,
|
||||
@@ -471,6 +477,8 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), r
|
||||
.returning('id');
|
||||
const photoId = inserted[0]?.id || inserted[0];
|
||||
|
||||
acceptedUpload(res, { video: isVideo, raw: extension.toLowerCase() === '.dng', s3: process.env.STORAGE_BACKEND === 's3' });
|
||||
|
||||
uploadedPhotos.push({
|
||||
id: photoId,
|
||||
filename: newFilename,
|
||||
@@ -1720,6 +1728,11 @@ router.post('/:eventId/chunked-upload/:uploadId/complete', adminAuth, requirePer
|
||||
'admin',
|
||||
category_id || null
|
||||
);
|
||||
if (uploadedPhotos.length) acceptedUpload(res, {
|
||||
video: isVideoMimeType(fileObj.mimetype),
|
||||
raw: path.extname(fileObj.originalname).toLowerCase() === '.dng',
|
||||
s3: process.env.STORAGE_BACKEND === 's3'
|
||||
});
|
||||
|
||||
// Clean up temp directory
|
||||
try {
|
||||
|
||||
@@ -54,6 +54,14 @@ router.post(
|
||||
res.json(await service.enable(req.body.consent_version))
|
||||
)
|
||||
);
|
||||
router.post(
|
||||
'/consent',
|
||||
wrap(async (req, res) => {
|
||||
if (!req.body || Object.keys(req.body).length !== 1 || req.body.consent_version !== 'usage-consent.v2')
|
||||
throw new ValidationError('Explicit usage v2 consent is required');
|
||||
res.json(await service.command('consent', { consent_version: 'usage-consent.v2' }));
|
||||
})
|
||||
);
|
||||
router.post(
|
||||
'/disable',
|
||||
wrap(async (_req, res) => res.json(await service.disable()))
|
||||
|
||||
@@ -176,6 +176,7 @@ router.post('/test', adminAuth, requirePermission('whatsapp.manage'), async (req
|
||||
};
|
||||
const testComponents = buildComponents(testData, language, params);
|
||||
const result = await sendWhatsAppMessage(phone, config, language, testComponents);
|
||||
require('../usage/capabilityEvidence').capabilityEvidence(res, 'whatsapp');
|
||||
res.json({ success: true, messageId: result.messageId });
|
||||
} catch (error) {
|
||||
logger.error('WhatsApp test send error:', error);
|
||||
|
||||
@@ -1091,7 +1091,7 @@ async function sendRawEmail({ to, cc, subject, html, text, attachments, accountK
|
||||
? await emailWebhookTransport.send(mail)
|
||||
: await tx.sendMail(mail);
|
||||
logger.info(`Manual email sent: ${info.messageId}`);
|
||||
return { messageId: info.messageId, html };
|
||||
return { messageId: info.messageId, html, transport: viaWebhook ? 'webhook' : 'smtp' };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,6 +17,12 @@ const {
|
||||
digest,
|
||||
canonical,
|
||||
FEATURE_KEYS,
|
||||
LEGACY_FEATURE_KEYS,
|
||||
CATALOG,
|
||||
CURRENT_SCHEMA_VERSION,
|
||||
CURRENT_CONSENT_VERSION,
|
||||
featureKeysFor,
|
||||
observesUse,
|
||||
LAYOUTS
|
||||
} = require('./protocol.cjs');
|
||||
|
||||
@@ -100,6 +106,10 @@ const parse = (value) => {
|
||||
};
|
||||
|
||||
class UsageService {
|
||||
schemaVersion(state) {
|
||||
return state?.consent_version === CURRENT_CONSENT_VERSION
|
||||
? CURRENT_SCHEMA_VERSION : 'usage.v1';
|
||||
}
|
||||
constructor(db, options = {}) {
|
||||
this.db = db;
|
||||
this.fetch = options.fetch || global.fetch;
|
||||
@@ -232,7 +242,10 @@ class UsageService {
|
||||
installation_id: state.installation_id,
|
||||
collector_url: collectorUrl,
|
||||
collector_error: collectorError,
|
||||
schema_version: 'usage.v1',
|
||||
schema_version: this.schemaVersion(state),
|
||||
available_schema_version: CURRENT_SCHEMA_VERSION,
|
||||
consent_version: state.consent_version || 'usage-consent.v1',
|
||||
consent_update_available: state.status === 'active' && this.schemaVersion(state) !== CURRENT_SCHEMA_VERSION,
|
||||
last_report_date: state.last_report_date,
|
||||
last_error: state.last_error,
|
||||
pending_action: state.pending_packet
|
||||
@@ -272,7 +285,7 @@ class UsageService {
|
||||
return this.status();
|
||||
}
|
||||
async enable(consent) {
|
||||
if (consent !== 'usage-consent.v1')
|
||||
if (!['usage-consent.v1', CURRENT_CONSENT_VERSION].includes(consent))
|
||||
throw new ValidationError('Explicit usage consent is required');
|
||||
// Read BEFORE the lease, deliberately. locked() claims the lease and then
|
||||
// reads the row in a second statement; a /disable completing between
|
||||
@@ -293,7 +306,7 @@ class UsageService {
|
||||
const identity = generateIdentity();
|
||||
const pending = makePacket(identity, 'register', 0, {
|
||||
consent_version: consent
|
||||
});
|
||||
}, this.schemaVersion({ consent_version: consent }));
|
||||
// Identity generation and the binding file are the slow part, and the
|
||||
// row still reads `disabled` throughout — which is why /disable could
|
||||
// not see an activation in flight and its conditional update matched
|
||||
@@ -308,6 +321,7 @@ class UsageService {
|
||||
.where({ id: 1, status: 'disabled', cancel_seq: cancelSeq })
|
||||
.update({
|
||||
status: 'activation_pending',
|
||||
consent_version: consent,
|
||||
notice_dismissed: formatBoolean(true),
|
||||
installation_id: identity.installation_id,
|
||||
public_key: identity.public_key,
|
||||
@@ -513,7 +527,18 @@ class UsageService {
|
||||
} else {
|
||||
ack.whereNot({ status: 'deletion_pending' });
|
||||
}
|
||||
await ack.update(update);
|
||||
if (packet.action === 'consent') {
|
||||
// Upgrade and reset the observation period atomically. A late receipt
|
||||
// must never re-enable collection after an intervening opt-out.
|
||||
await this.db.transaction(async (tx) => {
|
||||
const upgraded = await tx('product_usage_state')
|
||||
.where({ id: 1, status: 'active', installation_id: packet.installation_id })
|
||||
.update({ ...update, consent_version: CURRENT_CONSENT_VERSION });
|
||||
if (upgraded) await tx('product_usage_markers').delete();
|
||||
});
|
||||
} else {
|
||||
await ack.update(update);
|
||||
}
|
||||
await this.db('product_usage_state')
|
||||
.where({ id: 1, status: 'deletion_pending' })
|
||||
.update({ sequence: packet.sequence, pending_packet: null });
|
||||
@@ -563,7 +588,7 @@ class UsageService {
|
||||
await this.locked(async (state) => {
|
||||
if (state.status === 'disabled') return;
|
||||
if (state.status === 'deletion_pending') {
|
||||
const packet = makePacket(state, 'delete', Number(state.sequence), {});
|
||||
const packet = makePacket(state, 'delete', Number(state.sequence), {}, this.schemaVersion(state));
|
||||
state.pending_packet = JSON.stringify(packet);
|
||||
await this.db('product_usage_state')
|
||||
.where({ id: 1 })
|
||||
@@ -582,12 +607,13 @@ class UsageService {
|
||||
new Date(this.now()).toISOString().slice(0, 10)
|
||||
)
|
||||
return;
|
||||
const payload = await this.snapshot();
|
||||
const payload = await this.snapshot(this.schemaVersion(state));
|
||||
const packet = makePacket(
|
||||
state,
|
||||
'report',
|
||||
Number(state.sequence) + 1,
|
||||
payload
|
||||
payload,
|
||||
this.schemaVersion(state)
|
||||
);
|
||||
state.pending_packet = JSON.stringify(packet);
|
||||
// Only while still active. /disable clears pending_packet and moves the
|
||||
@@ -604,8 +630,8 @@ class UsageService {
|
||||
return this.status();
|
||||
}
|
||||
|
||||
async markUsed(features, { destinationBackup = false } = {}) {
|
||||
const allowed = [...new Set(features)].filter((f) =>
|
||||
async markUsed(features, { destinationBackup = false, legacyFeatures } = {}) {
|
||||
let allowed = [...new Set([...features, ...(legacyFeatures || [])])].filter((f) =>
|
||||
FEATURE_KEYS.includes(f)
|
||||
);
|
||||
if (!allowed.length) return;
|
||||
@@ -615,6 +641,10 @@ class UsageService {
|
||||
if (this.db.client.config.client === 'pg') query.forUpdate();
|
||||
const state = await query.first();
|
||||
if (!state || state.status !== 'active') return;
|
||||
const version = this.schemaVersion(state);
|
||||
if (legacyFeatures) allowed = version === 'usage.v1' ? legacyFeatures : features;
|
||||
allowed = allowed.filter((feature) => featureKeysFor(version).includes(feature) && observesUse(feature, version));
|
||||
if (!allowed.length) return;
|
||||
// Only when the operation actually writes to the configured backup
|
||||
// destination. Deriving this from "a backup ran while S3 is configured"
|
||||
// marked S3 as USED for a local database backup or a .picpeak export,
|
||||
@@ -624,17 +654,20 @@ class UsageService {
|
||||
const destination = await tx('app_settings')
|
||||
.where({ setting_key: 'backup_destination_type' })
|
||||
.first();
|
||||
if (destination && parse(destination.setting_value) === 's3')
|
||||
if (destination && parse(destination.setting_value) === 's3') {
|
||||
allowed.push('s3_storage');
|
||||
if (version === CURRENT_SCHEMA_VERSION) allowed.push('s3_backups');
|
||||
}
|
||||
}
|
||||
await tx('product_usage_markers')
|
||||
.insert(allowed.map((feature) => ({ feature })))
|
||||
.insert([...new Set(allowed)].map((feature) => ({ feature })))
|
||||
.onConflict('feature')
|
||||
.ignore();
|
||||
});
|
||||
}
|
||||
|
||||
async snapshot() {
|
||||
async snapshot(version) {
|
||||
version = version || this.schemaVersion(await this.state());
|
||||
const rows = await this.db('app_settings')
|
||||
.whereIn('setting_key', SETTING_KEYS)
|
||||
.select('setting_key', 'setting_value');
|
||||
@@ -642,7 +675,9 @@ class UsageService {
|
||||
rows.map((r) => [r.setting_key, parse(r.setting_value)])
|
||||
);
|
||||
const flagRows = await this.db('feature_flags')
|
||||
.whereIn('key', Object.values(FLAG_MAP))
|
||||
.whereIn('key', version === CURRENT_SCHEMA_VERSION
|
||||
? [...new Set([...Object.values(FLAG_MAP), 'incomingMail', ...Object.values(CATALOG.features).map((f) => f.flag).filter(Boolean)])]
|
||||
: Object.values(FLAG_MAP))
|
||||
.select('key', 'value');
|
||||
const flags = Object.fromEntries(
|
||||
flagRows.map((r) => [r.key, truth(r.value)])
|
||||
@@ -651,7 +686,7 @@ class UsageService {
|
||||
await this.db('product_usage_markers').pluck('feature')
|
||||
);
|
||||
const features = Object.fromEntries(
|
||||
FEATURE_KEYS.map((key) => [
|
||||
LEGACY_FEATURE_KEYS.map((key) => [
|
||||
key,
|
||||
{ configured: Boolean(flags[FLAG_MAP[key]]), used: used.has(key) }
|
||||
])
|
||||
@@ -746,11 +781,14 @@ class UsageService {
|
||||
features.custom_css.used = true;
|
||||
}
|
||||
const now = new Date(this.now()).toISOString();
|
||||
const expanded = version === CURRENT_SCHEMA_VERSION
|
||||
? await require('./expandedSnapshot').expandSnapshot(this.db, { features, flags, used, now: this.now() })
|
||||
: features;
|
||||
return {
|
||||
picpeak_version: this.version,
|
||||
report_date: now.slice(0, 10),
|
||||
generated_at: now,
|
||||
features,
|
||||
features: expanded,
|
||||
gallery_layouts: [...layouts].sort()
|
||||
};
|
||||
}
|
||||
@@ -768,13 +806,16 @@ class UsageService {
|
||||
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))
|
||||
if (!['feedback', 'vote', 'session', 'consent'].includes(action))
|
||||
throw new ValidationError('Invalid usage action');
|
||||
if (action === 'consent' && state.consent_version === CURRENT_CONSENT_VERSION)
|
||||
throw new ConflictError('Usage consent is already current');
|
||||
const packet = makePacket(
|
||||
state,
|
||||
action,
|
||||
Number(state.sequence) + 1,
|
||||
payload
|
||||
payload,
|
||||
action === 'consent' ? CURRENT_SCHEMA_VERSION : this.schemaVersion(state)
|
||||
);
|
||||
// Validate the complete packet before storing an un-sendable operation.
|
||||
verifyEnvelope(
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
'use strict';
|
||||
const { FEATURE_KEYS, observesUse } = require('./schema.cjs');
|
||||
|
||||
// Trusted route handlers call this AFTER their business operation succeeds.
|
||||
// Only fixed, allowlisted keys reach finish middleware. It still requires an
|
||||
// authenticated admin, a 2xx response and active consent before persisting.
|
||||
function capabilityEvidence(res, ...keys) {
|
||||
res.locals.productUsageFeatures = [...new Set([
|
||||
...(res.locals.productUsageFeatures || []),
|
||||
...keys.filter((key) => FEATURE_KEYS.includes(key) && observesUse(key))
|
||||
])];
|
||||
}
|
||||
function acceptedUpload(res, { video = false, raw = false, s3 = false } = {}) {
|
||||
capabilityEvidence(res, 'photo_management',
|
||||
...(video ? ['video_uploads'] : []),
|
||||
...(raw ? ['camera_raw_uploads'] : []),
|
||||
...(s3 ? ['s3_storage', 's3_photo_storage'] : []));
|
||||
}
|
||||
module.exports = { capabilityEvidence, acceptedUpload };
|
||||
@@ -0,0 +1,79 @@
|
||||
'use strict';
|
||||
|
||||
// A fixed capability allowlist, not a route/click log. Only the resulting keys
|
||||
// survive the request. No request body, query, path, IDs or response values are
|
||||
// passed to the usage service. Read-only status/health/options polls are absent.
|
||||
const WRITE = ['POST', 'PUT', 'PATCH', 'DELETE'];
|
||||
const RULES_V2 = [
|
||||
[WRITE, /^\/customers(?:\/|$)/, ['crm']],
|
||||
[WRITE, /^\/quotes(?:\/|$)/, ['crm', 'crm_quotes']],
|
||||
[WRITE, /^\/invoices(?:\/|$)/, ['crm', 'crm_invoices']],
|
||||
[WRITE, /^\/contracts(?:\/|$)/, ['crm', 'crm_contracts']],
|
||||
[WRITE, /^\/projects(?:\/|$)/, ['crm', 'crm_projects']],
|
||||
[['GET'], /^\/calendar\/items\/?$/, ['crm', 'crm_calendar']],
|
||||
[WRITE, /^\/customers\/[^/]+\/(?:hour-entries|bill-combined|trigger-monthly-bill)(?:\/|$)/, ['crm', 'crm_hours']],
|
||||
[['POST'], /^\/customers\/(?:invite|[^/]+\/send-invite)\/?$/, ['customer_portal']],
|
||||
[WRITE, /^\/deals\/[^/]+\/installment-plan\/?$/, ['crm', 'crm_installments']],
|
||||
[WRITE, /^\/(?:quotes\/presets|contracts\/blocks)(?:\/|$)/, ['document_templates']],
|
||||
[WRITE, /^\/expenses\/inbound(?:\/|$)/, ['accounting', 'accounting_incoming_invoices']],
|
||||
[WRITE, /^\/expenses(?:\/(?!inbound(?:\/|$))|$)/, ['accounting', 'accounting_expenses']],
|
||||
[WRITE, /^\/ledger(?:\/|$)/, ['accounting', 'accounting_ledger']],
|
||||
[['GET'], /^\/ledger\/export\/?$/, ['accounting', 'accounting_ledger']],
|
||||
[['GET'], /^\/tax-report(?:\/(?:pdf|csv))?\/?$/, ['accounting', 'accounting_tax_report']],
|
||||
[WRITE, /^\/workflows(?:\/|$)/, ['workflows']],
|
||||
[WRITE, /^\/newsletters(?:\/[^/]+)?\/?$/, ['newsletters']],
|
||||
[['POST'], /^\/newsletters\/[^/]+\/(?:test|queue|cancel)\/?$/, ['newsletters']],
|
||||
[WRITE, /^\/events\/[^/]+\/(?:faces|people)(?:\/|$)/, ['face_recognition']],
|
||||
[WRITE, /^\/events\/faces\/auto-categories\/?$/, ['face_recognition']],
|
||||
[['POST'], /^\/external-media\/events\/[^/]+\/import-external\/?$/, ['share_mounts']],
|
||||
[['POST'], /^\/events\/?$/, ['galleries']],
|
||||
[['PUT', 'DELETE'], /^\/events\/[^/]+\/?$/, ['galleries']],
|
||||
[['POST'], /^\/events\/[^/]+\/(?:publish|duplicate|toggle-status|extend|rename|reveal|reset-password)\/?$/, ['galleries']],
|
||||
[['POST'], /^\/events\/(?:bulk-archive|bulk-delete)\/?$/, ['galleries', 'archive_management']],
|
||||
[['POST'], /^\/events\/[^/]+\/archive\/?$/, ['archive_management']],
|
||||
[['POST'], /^\/archives\/[^/]+\/restore\/?$/, ['archive_management']],
|
||||
[['DELETE'], /^\/archives\/[^/]+\/?$/, ['archive_management']],
|
||||
[['GET'], /^\/archives\/[^/]+\/download\/?$/, ['archive_management', 'photo_exports']],
|
||||
[WRITE, /^\/(?:events|photos)\/[^/]+\/photos(?:\/|$)/, ['photo_management']],
|
||||
[['POST'], /^\/photos\/photos\/[^/]+\/retry\/?$/, ['photo_processing']],
|
||||
[['POST'], /^\/photos\/repair-(?:dimensions|capture-dates|orientation)\/?$/, ['photo_processing']],
|
||||
[['POST', 'PUT'], /^\/thumbnails\/(?:settings|regenerate|regenerate-previews)\/?$/, ['photo_processing']],
|
||||
[['POST'], /^\/photo-export\/[^/]+\/export\/?$/, ['photo_exports']],
|
||||
[['GET'], /^\/(?:events|photos)\/[^/]+\/photos\/[^/]+\/download\/?$/, ['photo_exports']],
|
||||
[['GET'], /^\/events\/[^/]+\/(?:qr|qr-print)\/?$/, ['gallery_sharing']],
|
||||
[['POST'], /^\/events\/[^/]+\/(?:send-gallery-email|resend-email)\/?$/, ['gallery_sharing']],
|
||||
[['POST'], /^\/events\/[^/]+\/short-urls\/?$/, ['gallery_sharing', 'short_links']],
|
||||
[['DELETE'], /^\/short-urls\/[^/]+\/?$/, ['short_links']],
|
||||
[WRITE, /^\/categories(?:\/|$)/, ['gallery_categories']],
|
||||
[WRITE, /^\/event-types(?:\/|$)/, ['event_types']],
|
||||
[WRITE, /^\/events\/[^/]+\/slideshow(?:\/|$)/, ['slideshow']],
|
||||
[['PUT'], /^\/settings\/slideshow\/?$/, ['slideshow']],
|
||||
[WRITE, /^\/transfers(?:\/|$)/, ['transfers']],
|
||||
[['GET'], /^\/transfers\/[^/]+\/(?:download|extra-files\/[^/]+\/download|uploads\/[^/]+\/download)\/?$/, ['transfers']],
|
||||
[['POST'], /^\/email\/send\/?$/, ['messaging']],
|
||||
[WRITE, /^\/email\/(?:accounts|item\/[^/]+\/[^/]+(?:\/state)?)\/?$/, ['messaging']],
|
||||
[WRITE, /^\/email\/templates(?:\/|$)/, ['email_templates']],
|
||||
[['PUT'], /^\/settings\/theme\/?$/, ['branding']],
|
||||
[WRITE, /^\/settings\/(?:branding|logo|favicon)(?:\/|$)/, ['branding']],
|
||||
[WRITE, /^\/events\/[^/]+\/logo\/?$/, ['branding']],
|
||||
[['PUT'], /^\/settings\/seo\/?$/, ['seo_customization']],
|
||||
[WRITE, /^\/cms\/pages(?:\/|$)/, ['cms']],
|
||||
[['POST'], /^\/webhooks\/[^/]+\/(?:test|deliveries\/[^/]+\/replay)\/?$/, ['webhooks']],
|
||||
[WRITE, /^\/users(?:\/(?![^/]+\/reset-password(?:\/|$))|$)/, ['admin_management']],
|
||||
[WRITE, /^\/roles(?:\/|$)/, ['admin_management']],
|
||||
[['POST'], /^\/restore\/start\/?$/, ['restore']],
|
||||
[['GET'], /^\/backup\/picpeak\/export\/?$/, ['backup', 'portable_backup']],
|
||||
[['POST'], /^\/backup\/picpeak\/import\/?$/, ['restore', 'portable_backup']],
|
||||
[['POST'], /^\/backup\/run\/?$/, ['backup']],
|
||||
[['POST'], /^\/database-backup\/backup\/?$/, ['backup', 'database_backup']],
|
||||
[['GET'], /^\/dashboard\/analytics\/?$/, ['analytics_dashboard']],
|
||||
[WRITE, /^\/feedback\/(?:feedback|word-filters)(?:\/|$)/, ['feedback_moderation']],
|
||||
[WRITE, /^\/events\/[^/]+\/guests(?:\/|$)/, ['guest_management']],
|
||||
[['GET'], /^\/events\/[^/]+\/guests\/(?:export-all|[^/]+\/export)\/?$/, ['guest_management']],
|
||||
];
|
||||
|
||||
function capabilityKeys(method, pathname) {
|
||||
return [...new Set(RULES_V2.filter(([methods, pattern]) => methods.includes(method) && pattern.test(pathname))
|
||||
.flatMap(([, , keys]) => keys))];
|
||||
}
|
||||
module.exports = { RULES_V2, capabilityKeys };
|
||||
@@ -0,0 +1,109 @@
|
||||
'use strict';
|
||||
const { CATALOG, emptyFeatures } = require('./schema.cjs');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
|
||||
const truth = (value) => value === true || value === 1 || value === '1';
|
||||
const parse = (value) => {
|
||||
for (let i = 0; i < 3 && typeof value === 'string'; i++) {
|
||||
try { const decoded = JSON.parse(value); if (decoded === value) break; value = decoded; }
|
||||
catch { break; }
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
// Technical configuration only. Never read photos, feedback contents, guest /
|
||||
// customer / admin profiles, messages, audit logs, delivery logs or counts.
|
||||
// Presence queries return a literal 1, not even a row's identifying primary key.
|
||||
async function expandSnapshot(db, { features, flags, used, now }) {
|
||||
const result = { ...emptyFeatures('usage.v2'), ...features };
|
||||
const effective = { analytics: true, userManagement: true, ...flags };
|
||||
if (!effective.quotes) effective.bills = false;
|
||||
if (effective.bills) effective.accounting = true;
|
||||
if (!effective.accounting) {
|
||||
effective.incomingInvoices = false;
|
||||
effective.expenses = false;
|
||||
effective.taxReport = false;
|
||||
}
|
||||
effective.clients = ['customerPortal', 'quotes', 'bills', 'contracts', 'projects', 'calendar', 'hoursLogging', 'newsletters']
|
||||
.some((flag) => effective[flag]);
|
||||
if (['1', 'true', 'yes'].includes(String(process.env.PICPEAK_SINGLE_CONTAINER || '').toLowerCase())) effective.faces = false;
|
||||
for (const [key, definition] of Object.entries(CATALOG.features)) {
|
||||
if (definition.configuration === 'builtin') result[key].configured = true;
|
||||
if (definition.flag) result[key].configured = Boolean(effective[definition.flag]);
|
||||
if (definition.used && key !== 'custom_css') result[key].used = used.has(key);
|
||||
if (!definition.used) delete result[key].used;
|
||||
}
|
||||
// Applied custom CSS is detected locally without any visitor observation.
|
||||
result.custom_css.used = features.custom_css.used;
|
||||
|
||||
const has = async (table, columns) => {
|
||||
if (!(await db.schema.hasTable(table))) return false;
|
||||
for (const column of columns) if (!(await db.schema.hasColumn(table, column))) return false;
|
||||
return true;
|
||||
};
|
||||
const exists = async (table, columns, filter) => {
|
||||
if (!(await has(table, columns))) return false;
|
||||
const query = db(table);
|
||||
filter(query);
|
||||
return Boolean(await query.select(db.raw('1 as present')).first());
|
||||
};
|
||||
const enabled = (table, column, filter = () => {}) => exists(table, [column], (query) => {
|
||||
query.where(column, formatBoolean(true)); filter(query);
|
||||
});
|
||||
const settingKeys = [
|
||||
'general_allowed_file_types', 'general_public_site_enabled',
|
||||
'download_resolution_picker_enabled', 'branding_watermark_enabled',
|
||||
'database_backup_enabled', 'backup_destination_type', 'backup_s3_bucket',
|
||||
'default_protection_level', 'enable_devtools_protection', 'enable_canvas_rendering'
|
||||
];
|
||||
const settings = Object.fromEntries((await db('app_settings')
|
||||
.whereIn('setting_key', settingKeys).select('setting_key', 'setting_value'))
|
||||
.map((row) => [row.setting_key, parse(row.setting_value)]));
|
||||
const extensions = new Set(String(settings.general_allowed_file_types || 'jpg,jpeg,png,webp')
|
||||
.toLowerCase().split(',').map((s) => s.trim().replace(/^\./, '')));
|
||||
result.video_uploads.configured = ['mp4', 'm4v', 'webm', 'mov', 'avi'].some((extension) => extensions.has(extension));
|
||||
result.camera_raw_uploads.configured = extensions.has('dng');
|
||||
result.public_site.configured = truth(settings.general_public_site_enabled);
|
||||
result.database_backup.configured = truth(settings.database_backup_enabled);
|
||||
result.email_webhook.configured = Boolean((process.env.EMAIL_WEBHOOK_URL || '').trim() && (process.env.EMAIL_WEBHOOK_SECRET || '').trim());
|
||||
result.s3_photo_storage.configured = process.env.STORAGE_BACKEND === 's3' &&
|
||||
Boolean(process.env.STORAGE_S3_BUCKET && process.env.STORAGE_S3_ACCESS_KEY && process.env.STORAGE_S3_SECRET_KEY);
|
||||
result.s3_backups.configured = settings.backup_destination_type === 's3' && Boolean(settings.backup_s3_bucket);
|
||||
result.crm_installments.configured = Boolean(effective.quotes || effective.bills);
|
||||
result.document_templates.configured = Boolean(effective.quotes || effective.contracts);
|
||||
const imapColumns = ['imap_host', 'imap_user', 'imap_pass'];
|
||||
const imapPresent = (query) => { for (const column of imapColumns) query.whereNotNull(column).whereNot(column, ''); };
|
||||
result.incoming_mail.configured = Boolean(effective.incomingMail) && (
|
||||
await exists('email_configs', imapColumns, imapPresent) ||
|
||||
await exists('mail_accounts', [...imapColumns, 'enabled'], (query) => { imapPresent(query); query.where('enabled', formatBoolean(true)); })
|
||||
);
|
||||
result.api_integration.configured = await exists('api_tokens', ['revoked_at', 'expires_at'], (query) => {
|
||||
query.whereNull('revoked_at').where((q) => q.whereNull('expires_at').orWhere('expires_at', '>', new Date(now).toISOString()));
|
||||
});
|
||||
result.webhooks.configured = await enabled('webhooks', 'active');
|
||||
for (const [key, column] of Object.entries({
|
||||
gallery_guest_uploads: 'allow_user_uploads', gallery_downloads: 'allow_downloads',
|
||||
gallery_client_access: 'client_access_enabled', gallery_watermarks: 'watermark_downloads'
|
||||
})) result[key].configured = await enabled('events', column);
|
||||
result.gallery_watermarks.configured ||= truth(settings.branding_watermark_enabled);
|
||||
result.gallery_reveal.configured = await exists('events', ['allow_user_uploads', 'reveal_mode'], (query) =>
|
||||
query.where({ allow_user_uploads: formatBoolean(true), reveal_mode: formatBoolean(true) }));
|
||||
result.gallery_expiration.configured = await exists('events', ['expires_at'], (query) => query.whereNotNull('expires_at'));
|
||||
result.download_resolution_picker.configured = truth(settings.download_resolution_picker_enabled) ||
|
||||
await enabled('events', 'download_resolution_picker_enabled');
|
||||
result.gallery_image_protection.configured = ['standard', 'enhanced', 'maximum'].includes(settings.default_protection_level) ||
|
||||
truth(settings.enable_devtools_protection) || truth(settings.enable_canvas_rendering);
|
||||
for (const column of ['disable_right_click', 'enable_devtools_protection', 'use_canvas_rendering'])
|
||||
result.gallery_image_protection.configured ||= await enabled('events', column);
|
||||
result.gallery_image_protection.configured ||= await exists('events', ['protection_level'], (query) =>
|
||||
query.whereIn('protection_level', ['standard', 'enhanced', 'maximum']));
|
||||
for (const [suffix, column] of Object.entries({
|
||||
likes: 'allow_likes', ratings: 'allow_ratings', comments: 'allow_comments',
|
||||
favorites: 'allow_favorites', reactions: 'allow_reactions', color_labels: 'allow_color_labels'
|
||||
})) result['gallery_feedback_' + suffix].configured = await exists('event_feedback_settings', ['feedback_enabled', column], (query) =>
|
||||
query.where({ feedback_enabled: formatBoolean(true), [column]: formatBoolean(true) }));
|
||||
result.gallery_guest_accounts.configured = await exists('event_feedback_settings', ['feedback_enabled', 'identity_mode'], (query) =>
|
||||
query.where('feedback_enabled', formatBoolean(true)).whereIn('identity_mode', ['guest', 'shared']));
|
||||
return result;
|
||||
}
|
||||
module.exports = { expandSnapshot };
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,13 +3,18 @@ const crypto = require("node:crypto");
|
||||
const Ajv = require("ajv");
|
||||
const {
|
||||
envelopeSchema,
|
||||
envelopeSchemas,
|
||||
CURRENT_SCHEMA_VERSION,
|
||||
FEATURE_KEYS,
|
||||
LAYOUTS,
|
||||
payloads,
|
||||
} = require("./schema.cjs");
|
||||
const validate = new Ajv({ allErrors: false, strict: true }).compile(
|
||||
envelopeSchema,
|
||||
);
|
||||
const ajv = new Ajv({ allErrors: false, strict: true });
|
||||
const validators = new Map(Object.entries(envelopeSchemas).map(
|
||||
([version, schema]) => [version, ajv.compile(schema)],
|
||||
));
|
||||
const validate = (envelope) =>
|
||||
Boolean(validators.get(envelope?.packet?.schema_version)?.(envelope));
|
||||
const MAX_BYTES = 16384;
|
||||
const MAX_AGE_MS = 5 * 60 * 1000;
|
||||
|
||||
@@ -56,9 +61,9 @@ function generateIdentity() {
|
||||
private_key: keys.privateKey.export({ format: "pem", type: "pkcs8" }),
|
||||
};
|
||||
}
|
||||
function makePacket(identity, action, sequence, payload) {
|
||||
function makePacket(identity, action, sequence, payload, schemaVersion = CURRENT_SCHEMA_VERSION) {
|
||||
return {
|
||||
schema_version: "usage.v1",
|
||||
schema_version: schemaVersion,
|
||||
installation_id: identity.installation_id,
|
||||
packet_id: crypto.randomUUID(),
|
||||
action,
|
||||
@@ -139,6 +144,7 @@ function verifyEnvelope(envelope, now = Date.now()) {
|
||||
return envelope.packet;
|
||||
}
|
||||
module.exports = {
|
||||
...require("./schema.cjs"),
|
||||
canonical,
|
||||
digest,
|
||||
generateIdentity,
|
||||
|
||||
+60
-119
@@ -1,138 +1,79 @@
|
||||
"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",
|
||||
// Vendored byte-identical in PicPeak. v1 stays immutable; a larger allowlist
|
||||
// has a new wire version and requires explicit, signed v2 consent.
|
||||
const CATALOG = require("./features.v2.json");
|
||||
const CURRENT_SCHEMA_VERSION = "usage.v2";
|
||||
const CURRENT_CONSENT_VERSION = "usage-consent.v2";
|
||||
const LEGACY_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 FEATURE_KEYS = Object.keys(CATALOG.features);
|
||||
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,
|
||||
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 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 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 featureKeysFor = (version = CURRENT_SCHEMA_VERSION) =>
|
||||
version === "usage.v1" ? LEGACY_FEATURE_KEYS : version === "usage.v2" ? FEATURE_KEYS : [];
|
||||
const observesUse = (key, version = CURRENT_SCHEMA_VERSION) =>
|
||||
version === "usage.v1" || CATALOG.features[key]?.measurement === "configuration_and_use";
|
||||
const emptyFeatures = (version = CURRENT_SCHEMA_VERSION) => Object.fromEntries(
|
||||
featureKeysFor(version).map(key => [key, {
|
||||
configured: false, ...(observesUse(key, version) ? { used: false } : {})
|
||||
}])
|
||||
);
|
||||
const report = object({
|
||||
picpeak_version: {
|
||||
type: "string",
|
||||
maxLength: 48,
|
||||
pattern: "^\\d+\\.\\d+\\.\\d+(?:-(?:alpha|beta|rc)\\.\\d+)?$",
|
||||
},
|
||||
const report = (version) => 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 },
|
||||
},
|
||||
features: object(Object.fromEntries(featureKeysFor(version).map(key => [
|
||||
key, object({ configured: boolean, ...(observesUse(key, version) ? { used: boolean } : {}) })
|
||||
]))),
|
||||
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,
|
||||
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,
|
||||
const makePayloads = (version) => ({
|
||||
register: object({ consent_version: { const: version === "usage.v1" ? "usage-consent.v1" : CURRENT_CONSENT_VERSION } }),
|
||||
report: report(version),
|
||||
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 = {
|
||||
...(version === "usage.v2" ? { consent: object({ consent_version: { const: CURRENT_CONSENT_VERSION } }) } : {}),
|
||||
});
|
||||
const payloadsByVersion = Object.fromEntries(["usage.v1", "usage.v2"].map(version => [version, makePayloads(version)]));
|
||||
const envelopeSchemas = Object.fromEntries(Object.entries(payloadsByVersion).map(([version, actions]) => [version, {
|
||||
$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.",
|
||||
$id: `https://usage.picpeak.app/schema/${version}.json`,
|
||||
title: `PicPeak ${version} 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_-]+$",
|
||||
},
|
||||
packet: { oneOf: Object.entries(actions).map(([action, payload]) => object({
|
||||
schema_version: { const: version },
|
||||
installation_id: hash, packet_id: uuid,
|
||||
sequence: { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER },
|
||||
action: { const: action }, payload,
|
||||
})) },
|
||||
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_-]+$" },
|
||||
}),
|
||||
}]));
|
||||
const envelopeSchema = envelopeSchemas[CURRENT_SCHEMA_VERSION];
|
||||
const payloads = payloadsByVersion[CURRENT_SCHEMA_VERSION];
|
||||
module.exports = {
|
||||
FEATURE_KEYS, LEGACY_FEATURE_KEYS, LAYOUTS, CATALOG, CURRENT_SCHEMA_VERSION,
|
||||
CURRENT_CONSENT_VERSION, featureKeysFor, observesUse, emptyFeatures,
|
||||
envelopeSchema, envelopeSchemas, payloads, payloadsByVersion,
|
||||
};
|
||||
module.exports = { FEATURE_KEYS, LAYOUTS, envelopeSchema, payloads };
|
||||
|
||||
Reference in New Issue
Block a user